diff --git a/AGENTS.md b/AGENTS.md index 6b0560d..60d1e6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, 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. +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, and a preflight cost-bound projection are implemented. Candidate-aware request production, 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. @@ -71,7 +71,7 @@ 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, pricing snapshots, and missing-pricing evaluator | +| `token-pilot-core` | Basic implementation complete | Domain records, pricing, calculator, registry, ledger manager, pricing snapshots, token count results, UTF-8 byte heuristic estimation, and a preflight cost-bound projection; versioned model snapshots and context admission are still required before reservation | | `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 | @@ -335,6 +335,10 @@ The active checklist is in `docs/30_DAY_MVP_REPORT.md`; detailed long-term works - Sample app E2E uses a fake Spring AI `ChatModel`; real provider API behavior is not yet verified. - `token-pilot-spring-ai-starter` does not exist in the current build; never use it as an install instruction until implemented and published. - Maven Central release consumption must be re-verified for both core and starter paths before announcing `0.1.0`. +- Preflight cost bounds must use one immutable pricing snapshot from calculation through reservation and reconciliation; resolving a mutable registry again by model/policy identifiers can mix prices from different requests. +- A preflight cost bound is not context admission evidence. The REQUEST token result and reserved output must be checked against the versioned model context window before a provider call or reservation is authorized. +- `DefaultPreflightCostEstimator` currently relies on the existing exclusive `TokenType` pricing shape; richer pricing combinations and finite/unbounded policy capabilities must be owned by a validated pricing policy snapshot rather than caller-supplied flags. +- The UTF-8 byte heuristic estimator returns `TEXT_ONLY` and uses `BYTE_LEVEL_BPE_UTF8` only as a byte-level safety-basis identifier; it is not an exact BPE implementation and must not be used as full-request admission evidence. ## Verification @@ -396,6 +400,19 @@ Stage and deploy a Central release: ## Update History +### 2026-08-10 + +- Added the UTF-8 byte heuristic estimator and made preflight cost calculation consume the exact immutable pricing snapshot carried by its context instead of re-resolving a mutable registry. +- Kept arithmetic failures inside the typed preflight unavailable-result contract for pricing inputs whose decimal scale cannot be represented by the calculation. + +### 2026-08-09 + +- Clarified that the preflight cost-bound projection is not context admission or atomic reservation, and documented the immutable pricing-snapshot requirement for later integration. + +### 2026-08-08 + +- Added framework-independent token count result contracts and conservative preflight cost bounds that preserve immutable pricing metadata, use exact decimal arithmetic, and return typed unavailable outcomes instead of zero-price fallbacks. + ### 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`. diff --git a/build.gradle b/build.gradle index 30428b2..8330de9 100644 --- a/build.gradle +++ b/build.gradle @@ -657,11 +657,9 @@ package compatibility; import io.tokenpilot.core.TokenEstimator; import io.tokenpilot.core.domain.Cost; -import io.tokenpilot.core.domain.TokenCountAccuracy; import io.tokenpilot.core.domain.TokenCountResult; import io.tokenpilot.core.domain.TokenCountScope; -import io.tokenpilot.core.domain.TokenEstimatorDescriptor; -import io.tokenpilot.core.domain.TokenizationBasis; +import io.tokenpilot.core.internal.LedgerComponents; import java.math.BigDecimal; import java.util.Currency; @@ -675,17 +673,13 @@ public final class CoreConsumer { throw new IllegalStateException("Token Pilot core consumer verification failed"); } - TokenCountResult expected = TokenCountResult.counted( - 0L, - 0L, - TokenCountAccuracy.EXACT, - TokenCountScope.TEXT_ONLY, - new TokenEstimatorDescriptor("consumer-estimator", "1"), - new TokenizationBasis("consumer-basis") - ); - TokenEstimator estimator = text -> expected; - TokenCountResult actual = estimator.estimate(""); - if (!actual.isCounted() || !actual.isExact() || actual.tokens().orElseThrow() != 0L) { + TokenEstimator estimator = LedgerComponents.utf8ByteHeuristicTokenEstimator(); + TokenCountResult actual = estimator.estimate("hello"); + if (!actual.isCounted() + || actual.isExact() + || actual.tokens().orElseThrow() != 2L + || actual.safeUpperBoundTokens().orElseThrow() != 5L + || actual.scope() != TokenCountScope.TEXT_ONLY) { throw new IllegalStateException("Token Pilot estimator consumer verification failed"); } System.out.println("token-pilot-core Java 25 consumer OK"); diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/PreflightCostEstimator.java b/token-pilot-core/src/main/java/io/tokenpilot/core/PreflightCostEstimator.java new file mode 100644 index 0000000..cd5d4c0 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/PreflightCostEstimator.java @@ -0,0 +1,40 @@ +package io.tokenpilot.core; + +import io.tokenpilot.core.domain.PreflightCostResult; +import io.tokenpilot.core.domain.PreflightPricingContext; +import io.tokenpilot.core.domain.TokenCountResult; + +/** + * 호출 전에 계산한 REQUEST 범위의 토큰 상한을 금액 상한으로 변환하는 Core 계약입니다. + * + *

이 계약은 비용을 계산할 뿐 context window 적합성이나 provider 호출 허가를 + * 판정하지 않습니다. 호출자는 요청 토큰 결과가 모델의 context admission을 통과했고, + * {@link PreflightPricingContext}가 계산에 사용할 하나의 불변 pricing snapshot을 + * 보관하는지 먼저 보장해야 합니다. 계산기는 다른 snapshot을 registry에서 다시 + * 조회하지 않습니다.

+ * + *

계산 가능한 경우에도 예약 근거로 사용할 값은 + * {@link PreflightCostResult.Bounded#safeUpperBoundCost()}뿐입니다. + * {@code estimatedCost}는 관찰과 표시를 위한 값입니다.

+ */ +public interface PreflightCostEstimator { + + /** + * atomic reservation에 사용할 호출 전 비용 상한을 계산합니다. + * 입력이 REQUEST 범위가 아니거나 tokenizer 기준이 맞지 않거나 가격 snapshot이 + * 없거나 문맥에 snapshot이 없으면 숫자 비용 대신 제한된 unavailable 결과를 반환해야 합니다. + * + * @param pricingContext canonical model, pricing policy, catalog version과 + * 검증된 pricing 조건을 담은 계산 문맥 + * @param requestInput 실제 전송 요청 전체를 계산한 REQUEST 범위의 token 결과 + * @param reservedOutputTokens 호출 전에 확보할 최대 출력 token 수 + * @return 계산 가능한 비용 상한 또는 그 사유를 담은 unavailable 결과 + * @throws NullPointerException 필수 인자가 {@code null}인 경우 + * @throws IllegalArgumentException reservedOutputTokens가 음수인 경우 + */ + PreflightCostResult estimate( + PreflightPricingContext pricingContext, + TokenCountResult requestInput, + long reservedOutputTokens + ); +} diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PreflightCostResult.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PreflightCostResult.java new file mode 100644 index 0000000..d6fcf78 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PreflightCostResult.java @@ -0,0 +1,152 @@ +package io.tokenpilot.core.domain; + +import java.util.Currency; +import java.util.Objects; + +/** + * 호출 전 비용 계산 결과입니다. 숫자 상한이 있는 결과와 계산 불가 결과를 + * sealed hierarchy로 분리해 0원과 pricing 부재를 혼동하지 않습니다. + */ +public sealed interface PreflightCostResult + permits PreflightCostResult.Bounded, PreflightCostResult.Unavailable { + + /** + * 하나의 불변 pricing snapshot으로 계산한 유한 비용 상한입니다. + * 내부 계산에서는 반올림하지 않으며, context admission을 통과했다는 의미는 아닙니다. + * 예약 계층은 반드시 {@code safeUpperBoundCost}를 사용해야 합니다. + * + * @param estimatedCost 관찰과 표시를 위한 예상 비용 + * @param safeUpperBoundCost 예약에 사용할 보수적인 비용 상한 + * @param inputEstimatedTokens 정보 제공용 입력 token 계산값 + * @param inputSafeUpperBoundTokens 보수적인 입력 token 안전 상한 + * @param reservedOutputTokens 예약한 최대 출력 token 수 + * @param canonicalModelId canonical model 식별자 + * @param pricingPolicyId 불변 pricing policy 식별자 + * @param catalogVersion 계산에 사용한 catalog 버전 + * @param pricingSnapshot 계산·예약·정산에 전달할 정확한 가격 snapshot + * @param estimatorDescriptor token estimator 식별 정보 + * @param tokenizationBasis 검증된 tokenizer 호환성 기준 + */ + record Bounded( + Cost estimatedCost, + Cost safeUpperBoundCost, + long inputEstimatedTokens, + long inputSafeUpperBoundTokens, + long reservedOutputTokens, + String canonicalModelId, + String pricingPolicyId, + String catalogVersion, + PricingSnapshot pricingSnapshot, + TokenEstimatorDescriptor estimatorDescriptor, + TokenizationBasis tokenizationBasis + ) implements PreflightCostResult { + + /** + * 비용, token 관계와 재현성 metadata를 검증합니다. + */ + public Bounded { + estimatedCost = Objects.requireNonNull(estimatedCost, "estimatedCost must not be null"); + safeUpperBoundCost = Objects.requireNonNull( + safeUpperBoundCost, + "safeUpperBoundCost must not be null" + ); + validateTokens(inputEstimatedTokens, inputSafeUpperBoundTokens, reservedOutputTokens); + canonicalModelId = requireText(canonicalModelId, "canonicalModelId"); + pricingPolicyId = requireText(pricingPolicyId, "pricingPolicyId"); + catalogVersion = requireText(catalogVersion, "catalogVersion"); + pricingSnapshot = Objects.requireNonNull(pricingSnapshot, "pricingSnapshot must not be null"); + if (!pricingSnapshot.modelId().equals(canonicalModelId) + || !pricingSnapshot.pricingPolicyId().equals(pricingPolicyId) + || !pricingSnapshot.catalogVersion().equals(catalogVersion)) { + throw new IllegalArgumentException("pricingSnapshot identity must match result metadata"); + } + estimatorDescriptor = Objects.requireNonNull( + estimatorDescriptor, + "estimatorDescriptor must not be null" + ); + tokenizationBasis = Objects.requireNonNull(tokenizationBasis, "tokenizationBasis must not be null"); + + if (!estimatedCost.currency().equals(safeUpperBoundCost.currency())) { + throw new IllegalArgumentException("estimated and safe upper bound currencies must match"); + } + if (!pricingSnapshot.currency().equals(safeUpperBoundCost.currency())) { + throw new IllegalArgumentException("pricingSnapshot currency must match cost currency"); + } + if (safeUpperBoundCost.compareTo(estimatedCost) < 0) { + throw new IllegalArgumentException("safeUpperBoundCost must be greater than or equal to estimatedCost"); + } + } + + /** + * 계산된 비용의 통화를 반환합니다. + * + * @return estimated cost와 safe upper bound cost가 공유하는 통화 + */ + public Currency currency() { + return safeUpperBoundCost.currency(); + } + } + + /** + * fail-closed 경계에서 숫자 비용으로 취급할 수 없는 결과입니다. + * 이 결과는 0원이나 임의의 기본 단가를 의미하지 않으며, provider 호출과 예약을 + * 계속할지 여부는 각 control 계층이 사유를 확인해 결정해야 합니다. + * + * @param reason 숫자 비용을 생성하지 못한 제한된 사유 + * @param canonicalModelId canonical model 식별자 + * @param pricingPolicyId 참조한 pricing policy 식별자 + * @param catalogVersion 참조한 catalog 버전 + * @param currency 모델이 요구한 비용 통화 + * @param reservedOutputTokens 요청한 최대 출력 token 수 + * @param estimatorDescriptor token estimator 식별 정보 + * @param tokenizationBasis token 결과의 tokenizer 호환성 기준 + */ + record Unavailable( + PreflightCostUnavailableReason reason, + String canonicalModelId, + String pricingPolicyId, + String catalogVersion, + Currency currency, + long reservedOutputTokens, + TokenEstimatorDescriptor estimatorDescriptor, + TokenizationBasis tokenizationBasis + ) implements PreflightCostResult { + + /** + * Unavailable 사유와 진단 metadata를 검증합니다. + */ + public Unavailable { + reason = Objects.requireNonNull(reason, "reason must not be null"); + canonicalModelId = requireText(canonicalModelId, "canonicalModelId"); + pricingPolicyId = requireText(pricingPolicyId, "pricingPolicyId"); + catalogVersion = requireText(catalogVersion, "catalogVersion"); + currency = Objects.requireNonNull(currency, "currency must not be null"); + if (reservedOutputTokens < 0) { + throw new IllegalArgumentException("reservedOutputTokens must be non-negative"); + } + estimatorDescriptor = Objects.requireNonNull( + estimatorDescriptor, + "estimatorDescriptor must not be null" + ); + tokenizationBasis = Objects.requireNonNull(tokenizationBasis, "tokenizationBasis must not be null"); + } + } + + private static void validateTokens(long estimated, long safeUpperBound, long reservedOutput) { + if (estimated < 0 || safeUpperBound < 0 || reservedOutput < 0) { + throw new IllegalArgumentException("token values must be non-negative"); + } + if (safeUpperBound < estimated) { + throw new IllegalArgumentException( + "inputSafeUpperBoundTokens must be greater than or equal to inputEstimatedTokens" + ); + } + } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } +} diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PreflightCostUnavailableReason.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PreflightCostUnavailableReason.java new file mode 100644 index 0000000..7a08569 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PreflightCostUnavailableReason.java @@ -0,0 +1,25 @@ +package io.tokenpilot.core.domain; + +/** + * 호출 전 보수적 비용 상한을 숫자로 확정할 수 없는 제한된 사유입니다. + */ +public enum PreflightCostUnavailableReason { + /** token 계산 결과를 사용할 수 없는 상태입니다. */ + COUNT_UNAVAILABLE, + /** 전체 REQUEST 범위를 포함하지 않은 token 결과입니다. */ + INCOMPLETE_SCOPE, + /** estimator와 model의 tokenizer 기준이 호환되지 않습니다. */ + INCOMPATIBLE_TOKENIZER, + /** 참조한 pricing policy snapshot을 찾지 못했습니다. */ + PRICING_NOT_FOUND, + /** 비용 계산에 필요한 기본 입력/출력 단가가 누락됐습니다. */ + INCOMPLETE_PRICING, + /** pricing policy가 호출 전에 유한한 상한을 제공할 수 없습니다. */ + UNBOUNDED_PRICING, + /** model과 pricing snapshot의 통화가 다릅니다. */ + CURRENCY_MISMATCH, + /** canonical model, policy 또는 catalog 식별자가 snapshot과 다릅니다. */ + PRICING_SNAPSHOT_MISMATCH, + /** 가격 정밀도가 계산 범위를 벗어나 정형 비용 결과를 만들 수 없습니다. */ + ARITHMETIC_FAILURE +} diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PreflightPricingContext.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PreflightPricingContext.java new file mode 100644 index 0000000..42a6541 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PreflightPricingContext.java @@ -0,0 +1,98 @@ +package io.tokenpilot.core.domain; + +import java.util.Currency; +import java.util.Objects; +import java.util.Optional; + +/** + * canonical model 해석 이후 preflight 비용 계산에 전달하는 불변 pricing projection입니다. + * + *

alias는 ModelRegistry에서 먼저 canonical model로 해석해야 하며, 이 문맥을 + * 임의의 문자열 조합으로 만들면 안 됩니다. {@code catalogVersion}, + * {@code pricingPolicyId}, 통화와 tokenizer 기준은 같은 모델 정의와 pricing + * snapshot에서 함께 파생되어야 합니다.

+ * + *

계산 가능한 문맥은 registry에서 한 번 확정한 동일한 불변 snapshot을 함께 + * 보관해야 합니다. 계산·예약·정산 과정에서 mutable registry를 다시 조회하면 + * 서로 다른 가격을 섞을 수 있습니다. 가격을 찾지 못한 문맥은 빈 snapshot으로 + * 만들 수 있으며, 계산기는 이를 {@code PRICING_NOT_FOUND}로 반환합니다. + * {@link UpperBoundCapability} 역시 호출자가 임의로 선언하는 값이 아니라 검증된 + * pricing policy의 결과여야 합니다.

+ * + * @param canonicalModelId canonical model 식별자 + * @param pricingPolicyId 불변 pricing policy 식별자 + * @param catalogVersion model과 pricing catalog의 버전 + * @param tokenizationBasis 모델이 허용하는 tokenizer 호환성 기준 + * @param currency 모델이 요구하는 비용 통화 + * @param upperBoundCapability pricing policy가 유한한 비용 상한을 제공할 수 있는지 나타내는 결과 + * @param pricingSnapshot 이 계산·예약·정산에 사용할 확정 가격 snapshot + */ +public record PreflightPricingContext( + String canonicalModelId, + String pricingPolicyId, + String catalogVersion, + TokenizationBasis tokenizationBasis, + Currency currency, + UpperBoundCapability upperBoundCapability, + Optional pricingSnapshot +) { + + /** 모든 식별자와 호환성 metadata가 비어 있지 않은지 검증합니다. */ + public PreflightPricingContext { + canonicalModelId = requireText(canonicalModelId, "canonicalModelId"); + pricingPolicyId = requireText(pricingPolicyId, "pricingPolicyId"); + catalogVersion = requireText(catalogVersion, "catalogVersion"); + tokenizationBasis = Objects.requireNonNull(tokenizationBasis, "tokenizationBasis must not be null"); + currency = Objects.requireNonNull(currency, "currency must not be null"); + upperBoundCapability = Objects.requireNonNull( + upperBoundCapability, + "upperBoundCapability must not be null" + ); + pricingSnapshot = Objects.requireNonNull(pricingSnapshot, "pricingSnapshot must not be null"); + } + + /** + * 아직 가격 snapshot을 찾지 못한 문맥을 생성합니다. + * 계산기는 이 문맥을 숫자 비용이 아닌 {@code PRICING_NOT_FOUND}로 처리합니다. + * + * @param canonicalModelId canonical model 식별자 + * @param pricingPolicyId pricing policy 식별자 + * @param catalogVersion model과 pricing catalog의 버전 + * @param tokenizationBasis tokenizer 호환성 기준 + * @param currency 비용 통화 + * @param upperBoundCapability 가격 정책의 상한 제공 가능 여부 + */ + public PreflightPricingContext( + String canonicalModelId, + String pricingPolicyId, + String catalogVersion, + TokenizationBasis tokenizationBasis, + Currency currency, + UpperBoundCapability upperBoundCapability + ) { + this( + canonicalModelId, + pricingPolicyId, + catalogVersion, + tokenizationBasis, + currency, + upperBoundCapability, + Optional.empty() + ); + } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } + + /** pricing policy가 호출 전에 유한한 보수적 상한을 제공할 수 있는지 나타냅니다. */ + public enum UpperBoundCapability { + /** 모든 적용 경로에 유한한 최대 단가가 있습니다. */ + FINITE, + /** 하나 이상의 적용 경로에 유한한 최대 단가가 없습니다. */ + UNBOUNDED + } +} diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultPreflightCostEstimator.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultPreflightCostEstimator.java new file mode 100644 index 0000000..67cd8c1 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultPreflightCostEstimator.java @@ -0,0 +1,194 @@ +package io.tokenpilot.core.internal; + +import io.tokenpilot.core.PreflightCostEstimator; +import io.tokenpilot.core.domain.Cost; +import io.tokenpilot.core.domain.PreflightCostResult; +import io.tokenpilot.core.domain.PreflightCostUnavailableReason; +import io.tokenpilot.core.domain.PreflightPricingContext; +import io.tokenpilot.core.domain.PricingSnapshot; +import io.tokenpilot.core.domain.TokenCountResult; +import io.tokenpilot.core.domain.TokenCountScope; +import io.tokenpilot.core.domain.TokenType; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.Objects; + +/** + * 불변 pricing snapshot의 배타적인 입력/출력 가격 경로에서 각각 최대 단가를 선택해 + * 같은 token을 중복 합산하지 않고 보수적인 비용 상한을 계산하는 기본 구현입니다. + * + *

이 구현은 비용만 계산하며 context admission이나 provider 호출을 허가하지 않습니다. + * 입력 문맥이 보관한 동일한 snapshot만 사용하며, 계산 중 registry를 다시 조회하지 + * 않습니다. 호출 계층은 그 snapshot을 예약·정산에도 그대로 전달해야 합니다.

+ */ +class DefaultPreflightCostEstimator implements PreflightCostEstimator { + + private static final int TOKENS_PER_K_SHIFT = 3; + + @Override + public PreflightCostResult estimate( + PreflightPricingContext pricingContext, + TokenCountResult requestInput, + long reservedOutputTokens + ) { + Objects.requireNonNull(pricingContext, "pricingContext must not be null"); + Objects.requireNonNull(requestInput, "requestInput must not be null"); + if (reservedOutputTokens < 0) { + throw new IllegalArgumentException("reservedOutputTokens must be non-negative"); + } + + if (!requestInput.isCounted()) { + return unavailable( + PreflightCostUnavailableReason.COUNT_UNAVAILABLE, + pricingContext, + requestInput, + reservedOutputTokens + ); + } + if (requestInput.scope() != TokenCountScope.REQUEST) { + return unavailable( + PreflightCostUnavailableReason.INCOMPLETE_SCOPE, + pricingContext, + requestInput, + reservedOutputTokens + ); + } + if (!pricingContext.tokenizationBasis().equals(requestInput.tokenizationBasis())) { + return unavailable( + PreflightCostUnavailableReason.INCOMPATIBLE_TOKENIZER, + pricingContext, + requestInput, + reservedOutputTokens + ); + } + if (pricingContext.upperBoundCapability() == PreflightPricingContext.UpperBoundCapability.UNBOUNDED) { + return unavailable( + PreflightCostUnavailableReason.UNBOUNDED_PRICING, + pricingContext, + requestInput, + reservedOutputTokens + ); + } + + if (pricingContext.pricingSnapshot().isEmpty()) { + return unavailable( + PreflightCostUnavailableReason.PRICING_NOT_FOUND, + pricingContext, + requestInput, + reservedOutputTokens + ); + } + + PricingSnapshot snapshot = pricingContext.pricingSnapshot().orElseThrow(); + if (!snapshot.modelId().equals(pricingContext.canonicalModelId()) + || !snapshot.pricingPolicyId().equals(pricingContext.pricingPolicyId()) + || !snapshot.catalogVersion().equals(pricingContext.catalogVersion())) { + return unavailable( + PreflightCostUnavailableReason.PRICING_SNAPSHOT_MISMATCH, + pricingContext, + requestInput, + reservedOutputTokens + ); + } + if (!snapshot.currency().equals(pricingContext.currency())) { + return unavailable( + PreflightCostUnavailableReason.CURRENCY_MISMATCH, + pricingContext, + requestInput, + reservedOutputTokens + ); + } + + Map rates = snapshot.rates(); + BigDecimal promptRate = rates.get(TokenType.PROMPT); + BigDecimal completionRate = rates.get(TokenType.COMPLETION); + if (promptRate == null || completionRate == null) { + return unavailable( + PreflightCostUnavailableReason.INCOMPLETE_PRICING, + pricingContext, + requestInput, + reservedOutputTokens + ); + } + + long inputEstimatedTokens = requestInput.tokens().orElseThrow(); + long inputSafeUpperBoundTokens = requestInput.safeUpperBoundTokens().orElseThrow(); + + BigDecimal conservativeInputRate = maxRate( + promptRate, + rates.getOrDefault(TokenType.CACHE_READ_PROMPT, promptRate), + rates.getOrDefault(TokenType.CACHE_CREATION_PROMPT, promptRate) + ); + BigDecimal conservativeOutputRate = maxRate( + completionRate, + rates.getOrDefault(TokenType.REASONING, completionRate) + ); + + Cost estimatedCost; + Cost safeUpperBoundCost; + try { + estimatedCost = new Cost( + costFor(inputEstimatedTokens, promptRate) + .add(costFor(reservedOutputTokens, completionRate)), + snapshot.currency() + ); + safeUpperBoundCost = new Cost( + costFor(inputSafeUpperBoundTokens, conservativeInputRate) + .add(costFor(reservedOutputTokens, conservativeOutputRate)), + snapshot.currency() + ); + } catch (ArithmeticException exception) { + return unavailable( + PreflightCostUnavailableReason.ARITHMETIC_FAILURE, + pricingContext, + requestInput, + reservedOutputTokens + ); + } + + return new PreflightCostResult.Bounded( + estimatedCost, + safeUpperBoundCost, + inputEstimatedTokens, + inputSafeUpperBoundTokens, + reservedOutputTokens, + snapshot.modelId(), + snapshot.pricingPolicyId(), + snapshot.catalogVersion(), + snapshot, + requestInput.estimatorDescriptor(), + requestInput.tokenizationBasis() + ); + } + + private PreflightCostResult.Unavailable unavailable( + PreflightCostUnavailableReason reason, + PreflightPricingContext pricingContext, + TokenCountResult requestInput, + long reservedOutputTokens + ) { + return new PreflightCostResult.Unavailable( + reason, + pricingContext.canonicalModelId(), + pricingContext.pricingPolicyId(), + pricingContext.catalogVersion(), + pricingContext.currency(), + reservedOutputTokens, + requestInput.estimatorDescriptor(), + requestInput.tokenizationBasis() + ); + } + + private BigDecimal maxRate(BigDecimal first, BigDecimal... remaining) { + BigDecimal maximum = first; + for (BigDecimal candidate : remaining) { + maximum = maximum.max(candidate); + } + return maximum; + } + + private BigDecimal costFor(long tokens, BigDecimal ratePerK) { + return ratePerK.multiply(BigDecimal.valueOf(tokens)).movePointLeft(TOKENS_PER_K_SHIFT); + } +} diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/HeuristicTokenEstimator.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/HeuristicTokenEstimator.java new file mode 100644 index 0000000..9f48af7 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/HeuristicTokenEstimator.java @@ -0,0 +1,80 @@ +package io.tokenpilot.core.internal; + +import io.tokenpilot.core.TokenEstimator; +import io.tokenpilot.core.domain.TokenCountAccuracy; +import io.tokenpilot.core.domain.TokenCountResult; +import io.tokenpilot.core.domain.TokenCountScope; +import io.tokenpilot.core.domain.TokenEstimatorDescriptor; +import io.tokenpilot.core.domain.TokenizationBasis; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * UTF-8 byte 길이를 기반으로 문자열의 text-only token 수를 추정합니다. + * + *

{@code BYTE_LEVEL_BPE_UTF8}는 byte-level tokenizer의 안전성 가정을 + * 나타내는 compatibility basis일 뿐, 이 구현이 exact BPE merge를 수행한다는 + * 뜻은 아닙니다. 결과는 전체 요청의 context admission 근거로 사용할 수 없습니다.

+ */ +final class HeuristicTokenEstimator implements TokenEstimator { + + private static final int BYTES_PER_ESTIMATED_TOKEN = 4; + private static final String ESTIMATOR_ID = "tokenpilot-utf8-byte-heuristic"; + private static final String ESTIMATOR_VERSION = "1"; + private static final String TOKENIZATION_BASIS_ID = "BYTE_LEVEL_BPE_UTF8"; + + private static final TokenEstimatorDescriptor ESTIMATOR_DESCRIPTOR = + new TokenEstimatorDescriptor( + ESTIMATOR_ID, + ESTIMATOR_VERSION + ); + + private static final TokenizationBasis TOKENIZATION_BASIS = + new TokenizationBasis(TOKENIZATION_BASIS_ID); + + /** + * 원문을 정규화하지 않고 UTF-8 byte 길이로 계산합니다. + * + * @param text 계산할 원문 + * @return text-only 휴리스틱 token 계산 결과 + * @throws NullPointerException text가 null인 경우 + * @throws IllegalArgumentException text에 malformed UTF-16이 포함된 경우 + */ + @Override + public TokenCountResult estimate(String text) { + Objects.requireNonNull(text, "text must not be null"); + + long utf8Bytes = utf8ByteLength(text); + long estimatedTokens = Math.ceilDiv(utf8Bytes, BYTES_PER_ESTIMATED_TOKEN); + + return TokenCountResult.counted( + estimatedTokens, + utf8Bytes, + TokenCountAccuracy.HEURISTIC, + TokenCountScope.TEXT_ONLY, + ESTIMATOR_DESCRIPTOR, + TOKENIZATION_BASIS + ); + } + + private static long utf8ByteLength(String text) { + CharsetEncoder encoder = StandardCharsets.UTF_8.newEncoder(); + encoder.onMalformedInput(CodingErrorAction.REPORT); + return encodedByteLength(encoder, text); + } + + private static long encodedByteLength(CharsetEncoder encoder, String text) { + try { + CharBuffer input = CharBuffer.wrap(text); + ByteBuffer encoded = encoder.encode(input); + return encoded.remaining(); + } catch (CharacterCodingException exception) { + throw new IllegalArgumentException("text contains invalid UTF-16", exception); + } + } +} diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/LedgerComponents.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/LedgerComponents.java index 8ced30b..b4d4882 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/LedgerComponents.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/LedgerComponents.java @@ -6,6 +6,8 @@ import io.tokenpilot.core.PricingEvaluator; import io.tokenpilot.core.PricingProvider; import io.tokenpilot.core.PricingRegistry; +import io.tokenpilot.core.PreflightCostEstimator; +import io.tokenpilot.core.TokenEstimator; import java.util.List; @@ -25,6 +27,28 @@ public static PricingEvaluator defaultPricingEvaluator() { return new DefaultPricingEvaluator(); } + /** + * 기본 preflight 비용 상한 계산기를 생성합니다. + * + *

계산기는 REQUEST 범위의 token 결과와 하나의 불변 pricing snapshot을 + * 보관한 문맥을 요구합니다. 이 factory는 context admission이나 atomic reservation을 + * 대신 수행하지 않습니다.

+ * + * @return 기본 preflight 비용 상한 계산기 + */ + public static PreflightCostEstimator defaultPreflightCostEstimator() { + return new DefaultPreflightCostEstimator(); + } + + /** + * UTF-8 byte 길이 기반의 text-only 휴리스틱 token estimator를 생성합니다. + * + * @return UTF-8 byte 휴리스틱 estimator + */ + public static TokenEstimator utf8ByteHeuristicTokenEstimator() { + return new HeuristicTokenEstimator(); + } + public static PricingRegistry inMemoryPricingRegistry(List providers) { return new InMemoryPricingRegistry(providers); } diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultPreflightCostEstimatorTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultPreflightCostEstimatorTest.java new file mode 100644 index 0000000..44670ea --- /dev/null +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultPreflightCostEstimatorTest.java @@ -0,0 +1,394 @@ +package io.tokenpilot.core.internal; + +import io.tokenpilot.core.PreflightCostEstimator; +import io.tokenpilot.core.PricingProvider; +import io.tokenpilot.core.PricingRegistry; +import io.tokenpilot.core.domain.PreflightCostResult; +import io.tokenpilot.core.domain.PreflightCostUnavailableReason; +import io.tokenpilot.core.domain.PreflightPricingContext; +import io.tokenpilot.core.domain.PricingPlan; +import io.tokenpilot.core.domain.PricingSnapshot; +import io.tokenpilot.core.domain.TokenCountAccuracy; +import io.tokenpilot.core.domain.TokenCountResult; +import io.tokenpilot.core.domain.TokenCountScope; +import io.tokenpilot.core.domain.TokenCountUnavailableReason; +import io.tokenpilot.core.domain.TokenEstimatorDescriptor; +import io.tokenpilot.core.domain.TokenType; +import io.tokenpilot.core.domain.TokenizationBasis; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.util.Arrays; +import java.util.Currency; +import java.util.Map; +import java.util.Optional; + +import static io.tokenpilot.core.domain.PreflightPricingContext.UpperBoundCapability.FINITE; +import static io.tokenpilot.core.domain.PreflightPricingContext.UpperBoundCapability.UNBOUNDED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DefaultPreflightCostEstimatorTest { + + private static final Currency USD = Currency.getInstance("USD"); + private static final TokenizationBasis BASIS = new TokenizationBasis("o200k_base"); + private static final TokenEstimatorDescriptor ESTIMATOR = + new TokenEstimatorDescriptor("request-estimator", "1"); + + @Test + @DisplayName("estimated cost와 보수적 safe upper bound를 분리해 계산한다") + void calculateEstimatedAndSafeUpperBoundCost() { + PreflightCostEstimator estimator = estimator(plan(Map.of( + TokenType.PROMPT, decimal("0.010"), + TokenType.CACHE_READ_PROMPT, decimal("0.002"), + TokenType.CACHE_CREATION_PROMPT, decimal("0.030"), + TokenType.COMPLETION, decimal("0.020"), + TokenType.REASONING, decimal("0.050") + ))); + + PreflightCostResult result = estimator.estimate( + context(FINITE, USD, PricingSnapshot.DEFAULT_CATALOG_VERSION), + requestCount(1_000, 1_200), + 500 + ); + + assertThat(result).isInstanceOf(PreflightCostResult.Bounded.class); + PreflightCostResult.Bounded bounded = (PreflightCostResult.Bounded) result; + assertThat(bounded.estimatedCost().value()).isEqualByComparingTo("0.020"); + assertThat(bounded.safeUpperBoundCost().value()).isEqualByComparingTo("0.061"); + assertThat(bounded.safeUpperBoundCost().compareTo(bounded.estimatedCost())).isGreaterThanOrEqualTo(0); + assertThat(bounded.inputEstimatedTokens()).isEqualTo(1_000); + assertThat(bounded.inputSafeUpperBoundTokens()).isEqualTo(1_200); + assertThat(bounded.reservedOutputTokens()).isEqualTo(500); + assertThat(bounded.currency()).isEqualTo(USD); + assertThat(bounded.canonicalModelId()).isEqualTo("model-v1"); + assertThat(bounded.pricingPolicyId()).isEqualTo("standard"); + assertThat(bounded.catalogVersion()).isEqualTo(PricingSnapshot.DEFAULT_CATALOG_VERSION); + assertThat(bounded.pricingSnapshot().rates()).containsEntry(TokenType.PROMPT, decimal("0.010")); + assertThat(bounded.estimatorDescriptor()).isEqualTo(ESTIMATOR); + assertThat(bounded.tokenizationBasis()).isEqualTo(BASIS); + } + + @Test + @DisplayName("cache와 reasoning 경로는 최대 단가만 선택해 token을 이중 과금하지 않는다") + void chooseConservativeRatesWithoutDoubleCountingTokens() { + PreflightCostEstimator estimator = estimator(plan(Map.of( + TokenType.PROMPT, decimal("1"), + TokenType.CACHE_READ_PROMPT, decimal("2"), + TokenType.CACHE_CREATION_PROMPT, decimal("3"), + TokenType.COMPLETION, decimal("4"), + TokenType.REASONING, decimal("5") + ))); + + PreflightCostResult.Bounded result = bounded(estimator.estimate( + context(FINITE, USD, PricingSnapshot.DEFAULT_CATALOG_VERSION), + requestCount(1_000, 1_000), + 1_000 + )); + + assertThat(result.safeUpperBoundCost().value()).isEqualByComparingTo("8"); + } + + @Test + @DisplayName("input safe bound와 reserved output이 증가하면 비용 상한은 감소하지 않는다") + void keepSafeUpperBoundMonotonic() { + PreflightCostEstimator estimator = estimator(plan(Map.of( + TokenType.PROMPT, decimal("0.01"), + TokenType.COMPLETION, decimal("0.03") + ))); + PreflightPricingContext context = context(FINITE, USD, PricingSnapshot.DEFAULT_CATALOG_VERSION); + + PreflightCostResult.Bounded base = bounded(estimator.estimate(context, requestCount(100, 120), 50)); + PreflightCostResult.Bounded moreInput = bounded(estimator.estimate(context, requestCount(100, 121), 50)); + PreflightCostResult.Bounded moreOutput = bounded(estimator.estimate(context, requestCount(100, 120), 51)); + + assertThat(moreInput.safeUpperBoundCost().compareTo(base.safeUpperBoundCost())).isGreaterThanOrEqualTo(0); + assertThat(moreOutput.safeUpperBoundCost().compareTo(base.safeUpperBoundCost())).isGreaterThanOrEqualTo(0); + } + + @Test + @DisplayName("TEXT_ONLY, unavailable count와 tokenizer mismatch는 숫자 비용을 만들지 않는다") + void rejectInvalidTokenEvidence() { + PreflightCostEstimator estimator = estimator(standardPlan()); + PreflightPricingContext context = context(FINITE, USD, PricingSnapshot.DEFAULT_CATALOG_VERSION); + + assertUnavailable( + estimator.estimate(context, counted(100, 120, TokenCountScope.TEXT_ONLY, BASIS), 10), + PreflightCostUnavailableReason.INCOMPLETE_SCOPE + ); + assertUnavailable( + estimator.estimate(context, unavailableCount(), 10), + PreflightCostUnavailableReason.COUNT_UNAVAILABLE + ); + assertUnavailable( + estimator.estimate( + context, + counted(100, 120, TokenCountScope.REQUEST, new TokenizationBasis("cl100k_base")), + 10 + ), + PreflightCostUnavailableReason.INCOMPATIBLE_TOKENIZER + ); + } + + @Test + @DisplayName("가격 미등록, 필수 단가 누락과 unbounded pricing은 typed unavailable이다") + void distinguishUnavailablePricingFromZeroCost() { + PreflightPricingContext finiteContext = context( + FINITE, + USD, + PricingSnapshot.DEFAULT_CATALOG_VERSION + ); + + assertUnavailable( + estimator().estimate(finiteContext, requestCount(100, 120), 10), + PreflightCostUnavailableReason.PRICING_NOT_FOUND + ); + assertUnavailable( + estimator(plan(Map.of(TokenType.PROMPT, decimal("0.01")))) + .estimate(finiteContext, requestCount(100, 120), 10), + PreflightCostUnavailableReason.INCOMPLETE_PRICING + ); + assertUnavailable( + estimator(standardPlan()).estimate( + context(UNBOUNDED, USD, PricingSnapshot.DEFAULT_CATALOG_VERSION), + requestCount(100, 120), + 10 + ), + PreflightCostUnavailableReason.UNBOUNDED_PRICING + ); + } + + @Test + @DisplayName("모든 필수 단가가 명시적인 0인 정책만 0원 bound를 만든다") + void allowExplicitFreePricingPolicy() { + PreflightCostEstimator estimator = estimator(plan(Map.of( + TokenType.PROMPT, BigDecimal.ZERO, + TokenType.COMPLETION, BigDecimal.ZERO + ))); + + PreflightCostResult.Bounded result = bounded(estimator.estimate( + context(FINITE, USD, PricingSnapshot.DEFAULT_CATALOG_VERSION), + requestCount(1_000, 2_000), + 1_000 + )); + + assertThat(result.estimatedCost().value()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.safeUpperBoundCost().value()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("currency와 snapshot identity mismatch를 합산하거나 변환하지 않는다") + void rejectCurrencyAndSnapshotMismatch() { + PreflightCostEstimator estimator = estimator(standardPlan()); + + assertUnavailable( + estimator.estimate( + context(FINITE, Currency.getInstance("EUR"), PricingSnapshot.DEFAULT_CATALOG_VERSION), + requestCount(100, 120), + 10 + ), + PreflightCostUnavailableReason.CURRENCY_MISMATCH + ); + assertUnavailable( + estimator.estimate( + context(FINITE, USD, "catalog-v2"), + requestCount(100, 120), + 10 + ), + PreflightCostUnavailableReason.PRICING_SNAPSHOT_MISMATCH + ); + } + + @Test + @DisplayName("registry 가격이 바뀌어도 문맥에 담긴 snapshot으로 계산한다") + void usesCapturedPricingSnapshotAfterRegistryUpdate() { + PricingPlan initialPlan = plan(Map.of( + TokenType.PROMPT, decimal("10"), + TokenType.COMPLETION, decimal("10") + )); + PricingPlan updatedPlan = plan(Map.of( + TokenType.PROMPT, decimal("1"), + TokenType.COMPLETION, decimal("1") + )); + PricingProvider provider = () -> Arrays.asList(initialPlan); + PricingRegistry registry = LedgerComponents.inMemoryPricingRegistry(java.util.List.of(provider)); + PricingSnapshot captured = registry.resolveSnapshot("model-v1", "standard").orElseThrow(); + registry.registerPlan(updatedPlan); + + PreflightPricingContext context = withSnapshot( + context(FINITE, USD, PricingSnapshot.DEFAULT_CATALOG_VERSION), + captured + ); + PreflightCostResult.Bounded result = bounded( + LedgerComponents.defaultPreflightCostEstimator().estimate( + context, + requestCount(1_000, 1_000), + 0 + ) + ); + + assertThat(result.safeUpperBoundCost().value()).isEqualByComparingTo("10"); + } + + @Test + @DisplayName("계산할 수 없는 BigDecimal 정밀도는 정형 unavailable 결과로 반환한다") + void returnsUnavailableForUnsupportedDecimalScale() { + BigDecimal extremeRate = new BigDecimal(BigInteger.ONE, Integer.MAX_VALUE); + PricingSnapshot snapshot = new PricingSnapshot( + "model-v1", + "standard", + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.EPOCH, + Map.of( + TokenType.PROMPT, extremeRate, + TokenType.COMPLETION, BigDecimal.ZERO + ), + USD + ); + + PreflightCostResult result = LedgerComponents.defaultPreflightCostEstimator().estimate( + withSnapshot(context(FINITE, USD, PricingSnapshot.DEFAULT_CATALOG_VERSION), snapshot), + requestCount(1, 1), + 0 + ); + + assertUnavailable(result, PreflightCostUnavailableReason.ARITHMETIC_FAILURE); + } + + @Test + @DisplayName("Long.MAX_VALUE token에서도 overflow 없이 BigDecimal 정밀도를 보존한다") + void preservePrecisionForVeryLargeTokenCounts() { + BigDecimal promptRate = decimal("0.12345678901234567890123456789"); + BigDecimal completionRate = decimal("0.98765432109876543210987654321"); + PreflightCostEstimator estimator = estimator(plan(Map.of( + TokenType.PROMPT, promptRate, + TokenType.COMPLETION, completionRate + ))); + + PreflightCostResult.Bounded result = bounded(estimator.estimate( + context(FINITE, USD, PricingSnapshot.DEFAULT_CATALOG_VERSION), + requestCount(Long.MAX_VALUE, Long.MAX_VALUE), + Long.MAX_VALUE + )); + BigDecimal expected = promptRate.multiply(BigDecimal.valueOf(Long.MAX_VALUE)).movePointLeft(3) + .add(completionRate.multiply(BigDecimal.valueOf(Long.MAX_VALUE)).movePointLeft(3)); + + assertThat(result.estimatedCost().value()).isEqualByComparingTo(expected); + assertThat(result.safeUpperBoundCost().value()).isEqualByComparingTo(expected); + } + + @Test + @DisplayName("reserved output token은 음수일 수 없다") + void rejectNegativeReservedOutputTokens() { + PreflightCostEstimator estimator = estimator(standardPlan()); + + assertThatThrownBy(() -> estimator.estimate( + context(FINITE, USD, PricingSnapshot.DEFAULT_CATALOG_VERSION), + requestCount(100, 120), + -1 + )).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reservedOutputTokens"); + } + + private static PreflightCostEstimator estimator(PricingPlan... plans) { + PricingSnapshot snapshot = plans.length == 0 + ? null + : PricingSnapshot.from(plans[0], PricingSnapshot.DEFAULT_CATALOG_VERSION, Instant.EPOCH); + PreflightCostEstimator delegate = LedgerComponents.defaultPreflightCostEstimator(); + return (context, requestInput, reservedOutputTokens) -> delegate.estimate( + withSnapshot(context, snapshot), + requestInput, + reservedOutputTokens + ); + } + + private static PreflightPricingContext withSnapshot( + PreflightPricingContext context, + PricingSnapshot snapshot + ) { + return new PreflightPricingContext( + context.canonicalModelId(), + context.pricingPolicyId(), + context.catalogVersion(), + context.tokenizationBasis(), + context.currency(), + context.upperBoundCapability(), + Optional.ofNullable(snapshot) + ); + } + + private static PricingPlan standardPlan() { + return plan(Map.of( + TokenType.PROMPT, decimal("0.01"), + TokenType.COMPLETION, decimal("0.03") + )); + } + + private static PricingPlan plan(Map rates) { + return new PricingPlan("model-v1", "standard", rates, USD); + } + + private static PreflightPricingContext context( + PreflightPricingContext.UpperBoundCapability capability, + Currency currency, + String catalogVersion + ) { + return new PreflightPricingContext( + "model-v1", + "standard", + catalogVersion, + BASIS, + currency, + capability + ); + } + + private static TokenCountResult requestCount(long estimated, long safeUpperBound) { + return counted(estimated, safeUpperBound, TokenCountScope.REQUEST, BASIS); + } + + private static TokenCountResult counted( + long estimated, + long safeUpperBound, + TokenCountScope scope, + TokenizationBasis basis + ) { + return TokenCountResult.counted( + estimated, + safeUpperBound, + estimated == safeUpperBound ? TokenCountAccuracy.EXACT : TokenCountAccuracy.HEURISTIC, + scope, + ESTIMATOR, + basis + ); + } + + private static TokenCountResult unavailableCount() { + return TokenCountResult.unavailable( + TokenCountUnavailableReason.ESTIMATOR_UNAVAILABLE, + TokenCountScope.REQUEST, + ESTIMATOR, + BASIS + ); + } + + private static PreflightCostResult.Bounded bounded(PreflightCostResult result) { + assertThat(result).isInstanceOf(PreflightCostResult.Bounded.class); + return (PreflightCostResult.Bounded) result; + } + + private static void assertUnavailable( + PreflightCostResult result, + PreflightCostUnavailableReason reason + ) { + assertThat(result).isInstanceOf(PreflightCostResult.Unavailable.class); + assertThat(((PreflightCostResult.Unavailable) result).reason()).isEqualTo(reason); + } + + private static BigDecimal decimal(String value) { + return new BigDecimal(value); + } +} diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/HeuristicTokenEstimatorTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/HeuristicTokenEstimatorTest.java new file mode 100644 index 0000000..a620d09 --- /dev/null +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/HeuristicTokenEstimatorTest.java @@ -0,0 +1,177 @@ +package io.tokenpilot.core.internal; + +import io.tokenpilot.core.TokenEstimator; +import io.tokenpilot.core.domain.TokenCountAccuracy; +import io.tokenpilot.core.domain.TokenCountResult; +import io.tokenpilot.core.domain.TokenCountScope; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.text.Normalizer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HeuristicTokenEstimatorTest { + + private final TokenEstimator estimator = LedgerComponents.utf8ByteHeuristicTokenEstimator(); + + @Test + @DisplayName("빈 문자열은 estimate와 upper bound가 0인 결과로 계산한다") + void estimatesEmptyTextWithZeroEstimateAndUpperBound() { + TokenCountResult result = estimator.estimate(""); + + assertThat(result).isNotNull(); + assertThat(result.isCounted()).isTrue(); + assertThat(result.isUnavailable()).isFalse(); + assertThat(result.tokens()).hasValue(0L); + assertThat(result.safeUpperBoundTokens()).hasValue(0L); + assertThat(result.isExact()).isFalse(); + assertThat(result.accuracy()).contains(TokenCountAccuracy.HEURISTIC); + assertThat(result.scope()).isEqualTo(TokenCountScope.TEXT_ONLY); + } + + @Test + @DisplayName("4로 나누어떨어지는 ASCII byte 길이로 estimate를 계산한다") + void estimatesAsciiTextWhenUtf8ByteLengthIsDivisibleByFour() { + TokenCountResult result = estimator.estimate("four"); + + assertThat(result.tokens()).hasValue(1L); + assertThat(result.safeUpperBoundTokens()).hasValue(4L); + } + + @Test + @DisplayName("4보다 짧은 ASCII byte 길이의 estimate를 올림한다") + void roundsUpAsciiTextShorterThanFourBytes() { + TokenCountResult result = estimator.estimate("abc"); + + assertThat(result.tokens()).hasValue(1L); + assertThat(result.safeUpperBoundTokens()).hasValue(3L); + } + + @Test + @DisplayName("나머지가 있는 ASCII byte 길이의 estimate를 올림한다") + void roundsUpAsciiTextWhenUtf8ByteLengthHasRemainder() { + TokenCountResult result = estimator.estimate("hello"); + + assertThat(result.tokens()).hasValue(2L); + assertThat(result.safeUpperBoundTokens()).hasValue(5L); + } + + @Test + @DisplayName("한글 문자열을 UTF-8 byte 길이로 계산한다") + void estimatesKoreanTextFromUtf8ByteLength() { + TokenCountResult result = estimator.estimate("한글"); + + assertThat(result.tokens()).hasValue(2L); + assertThat(result.safeUpperBoundTokens()).hasValue(6L); + assertHeuristicTextOnlyMetadata(result); + } + + @Test + @DisplayName("ASCII와 한글이 섞인 문자열을 UTF-8 byte 길이로 계산한다") + void estimatesMixedAsciiAndKoreanTextFromUtf8ByteLength() { + TokenCountResult result = estimator.estimate("A한"); + + assertThat(result.tokens()).hasValue(1L); + assertThat(result.safeUpperBoundTokens()).hasValue(4L); + assertHeuristicTextOnlyMetadata(result); + } + + @Test + @DisplayName("모든 결과에 고정된 estimator와 tokenization 기준을 포함한다") + void includesFixedEstimatorAndTokenizationMetadata() { + TokenCountResult result = estimator.estimate(""); + + assertThat(result.estimatorDescriptor().estimatorId()) + .isEqualTo("tokenpilot-utf8-byte-heuristic"); + assertThat(result.estimatorDescriptor().estimatorVersion()).isEqualTo("1"); + assertThat(result.tokenizationBasis().id()).isEqualTo("BYTE_LEVEL_BPE_UTF8"); + } + + @Test + @DisplayName("null 입력을 거부한다") + void rejectsNullText() { + assertThatThrownBy(() -> estimator.estimate(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("짝이 없는 high surrogate를 거부한다") + void rejectsUnpairedHighSurrogate() { + assertThatThrownBy(() -> estimator.estimate("\uD800")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("짝이 없는 low surrogate를 거부한다") + void rejectsUnpairedLowSurrogate() { + assertThatThrownBy(() -> estimator.estimate("\uDC00")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("올바른 surrogate pair를 UTF-8 byte 길이로 계산한다") + void estimatesValidSurrogatePairFromUtf8ByteLength() { + TokenCountResult result = estimator.estimate("\uD83D\uDE00"); + + assertThat(result.tokens()).hasValue(1L); + assertThat(result.safeUpperBoundTokens()).hasValue(4L); + assertHeuristicTextOnlyMetadata(result); + } + + @Test + @DisplayName("precomposed 문자열을 원문의 UTF-8 byte 길이로 계산한다") + void estimatesPrecomposedTextWithoutNormalization() { + TokenCountResult result = estimator.estimate("\u00E9"); + + assertThat(result.tokens()).hasValue(1L); + assertThat(result.safeUpperBoundTokens()).hasValue(2L); + } + + @Test + @DisplayName("combining 문자열을 원문의 UTF-8 byte 길이로 계산한다") + void estimatesCombiningTextWithoutNormalization() { + TokenCountResult result = estimator.estimate("e\u0301"); + + assertThat(result.tokens()).hasValue(1L); + assertThat(result.safeUpperBoundTokens()).hasValue(3L); + } + + @Test + @DisplayName("canonical equivalent 문자열을 같은 byte 길이로 정규화하지 않는다") + void preservesDifferentByteLengthsForCanonicalEquivalentText() { + String precomposed = "\u00E9"; + String combining = "e\u0301"; + assertThat(Normalizer.normalize(combining, Normalizer.Form.NFC)) + .isEqualTo(precomposed); + + TokenCountResult precomposedResult = estimator.estimate(precomposed); + TokenCountResult combiningResult = estimator.estimate(combining); + + assertThat(precomposedResult.safeUpperBoundTokens()).hasValue(2L); + assertThat(combiningResult.safeUpperBoundTokens()).hasValue(3L); + } + + @ParameterizedTest + @ValueSource(strings = {"", "hello", "한글", "A한", "\uD83D\uDE00", "\u00E9", "e\u0301"}) + @DisplayName("모든 정상 입력 결과는 HEURISTIC/TEXT_ONLY 계약을 유지한다") + void preservesHeuristicTextOnlyContractForValidText(String text) { + TokenCountResult result = estimator.estimate(text); + + assertHeuristicTextOnlyMetadata(result); + assertThat(result.isExact()).isFalse(); + assertThat(result.tokens().orElseThrow()) + .isLessThanOrEqualTo(result.safeUpperBoundTokens().orElseThrow()); + } + + private static void assertHeuristicTextOnlyMetadata(TokenCountResult result) { + assertThat(result.accuracy()).contains(TokenCountAccuracy.HEURISTIC); + assertThat(result.scope()).isEqualTo(TokenCountScope.TEXT_ONLY); + assertThat(result.estimatorDescriptor().estimatorId()) + .isEqualTo("tokenpilot-utf8-byte-heuristic"); + assertThat(result.estimatorDescriptor().estimatorVersion()).isEqualTo("1"); + assertThat(result.tokenizationBasis().id()).isEqualTo("BYTE_LEVEL_BPE_UTF8"); + } +}