From a2d49217202f9abdd1bd6900fbb95348a95323ee Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 06:59:26 +0900 Subject: [PATCH 01/32] =?UTF-8?q?feat(core):=20=EA=B0=80=EA=B2=A9=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5/=EC=8B=A4=ED=8C=A8=EA=B0=80=20=EA=B5=AC=EB=B6=84?= =?UTF-8?q?=EB=90=98=EB=8A=94=20typed=20result=EB=A1=9C=20=ED=91=9C?= =?UTF-8?q?=ED=98=84=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/domain/PricingResolution.java | 31 ++++++++ .../core/domain/PricingResolutionStatus.java | 8 ++ .../core/domain/PricingResolutionTest.java | 74 +++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolution.java create mode 100644 token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolutionStatus.java create mode 100644 token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolution.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolution.java new file mode 100644 index 0000000..e48d233 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolution.java @@ -0,0 +1,31 @@ +package io.tokenpilot.core.domain; + +import java.util.Objects; + +public record PricingResolution( + PricingResolutionStatus status +) { + public PricingResolution { + Objects.requireNonNull(status, "status must not be null"); + } + + public static PricingResolution resolved() { + return new PricingResolution(PricingResolutionStatus.RESOLVED); + } + + public static PricingResolution missingPlan() { + return new PricingResolution(PricingResolutionStatus.MISSING_PLAN); + } + + public static PricingResolution missingRate() { + return new PricingResolution(PricingResolutionStatus.MISSING_RATE); + } + + public static PricingResolution currencyMismatch() { + return new PricingResolution(PricingResolutionStatus.CURRENCY_MISMATCH); + } + + public boolean isResolved() { + return status == PricingResolutionStatus.RESOLVED; + } +} \ No newline at end of file diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolutionStatus.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolutionStatus.java new file mode 100644 index 0000000..03fd0bd --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolutionStatus.java @@ -0,0 +1,8 @@ +package io.tokenpilot.core.domain; + +public enum PricingResolutionStatus { + RESOLVED, + MISSING_PLAN, + MISSING_RATE, + CURRENCY_MISMATCH +} \ No newline at end of file diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java new file mode 100644 index 0000000..6fd595e --- /dev/null +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java @@ -0,0 +1,74 @@ +package io.tokenpilot.core.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PricingResolutionTest { + + @Test + @DisplayName("PricingResolutionStatus는 public API에서 bounded 상태값을 제공한다") + void exposesPricingResolutionStatuses() { + assertThat(PricingResolutionStatus.values()) + .containsExactly( + PricingResolutionStatus.RESOLVED, + PricingResolutionStatus.MISSING_PLAN, + PricingResolutionStatus.MISSING_RATE, + PricingResolutionStatus.CURRENCY_MISMATCH + ); + } + + @Test + @DisplayName("RESOLVED 결과는 성공 상태로 표현된다") + void resolvedIsSuccessful() { + PricingResolution resolution = PricingResolution.resolved(); + + assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.RESOLVED); + assertThat(resolution.isResolved()).isTrue(); + } + + @Test + @DisplayName("MISSING_PLAN 결과는 실패 상태로 표현된다") + void missingPlanIsNotResolved() { + PricingResolution resolution = PricingResolution.missingPlan(); + + assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.MISSING_PLAN); + assertThat(resolution.isResolved()).isFalse(); + } + + @Test + @DisplayName("MISSING_RATE 결과는 실패 상태로 표현된다") + void missingRateIsNotResolved() { + PricingResolution resolution = PricingResolution.missingRate(); + + assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.MISSING_RATE); + assertThat(resolution.isResolved()).isFalse(); + } + + @Test + @DisplayName("CURRENCY_MISMATCH 결과는 실패 상태로 표현된다") + void currencyMismatchIsNotResolved() { + PricingResolution resolution = PricingResolution.currencyMismatch(); + + assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.CURRENCY_MISMATCH); + assertThat(resolution.isResolved()).isFalse(); + } + + @Test + @DisplayName("PricingResolution은 null status를 허용하지 않는다") + void rejectsNullStatus() { + assertThatThrownBy(() -> new PricingResolution(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("status must not be null"); + } + + @Test + @DisplayName("PricingResolutionStatus 자체가 low-cardinality pricing miss reason이다") + void statusItselfIsLowCardinalityReason() { + assertThat(PricingResolutionStatus.MISSING_PLAN.name()).isEqualTo("MISSING_PLAN"); + assertThat(PricingResolutionStatus.MISSING_RATE.name()).isEqualTo("MISSING_RATE"); + assertThat(PricingResolutionStatus.CURRENCY_MISMATCH.name()).isEqualTo("CURRENCY_MISMATCH"); + } +} From eefc6c36eaaea0e47af04c065ef7d80582691934 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 07:21:19 +0900 Subject: [PATCH 02/32] =?UTF-8?q?feat(core):=20=EA=B0=80=EA=B2=A9=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EA=B0=80=20=EC=97=86=EB=8A=94=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EC=99=80=20=EB=AA=85=EC=8B=9C=EC=A0=81=EC=9D=B8=200?= =?UTF-8?q?=EC=9B=90=20=EA=B0=80=EA=B2=A9=EC=9D=84=20=EC=84=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EA=B2=B0=EA=B3=BC=EB=A1=9C=20=ED=91=9C?= =?UTF-8?q?=ED=98=84=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/tokenpilot/core/PricingRegistry.java | 15 ++++ .../tokenpilot/core/domain/PricingPlan.java | 13 ++++ .../core/domain/PricingPlanTest.java | 74 +++++++++++++++++++ .../internal/InMemoryPricingRegistryTest.java | 14 +++- 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java b/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java index 673c02a..8165bc4 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java @@ -1,6 +1,8 @@ package io.tokenpilot.core; import io.tokenpilot.core.domain.PricingPlan; +import io.tokenpilot.core.domain.PricingResolution; +import io.tokenpilot.core.domain.TokenType; import java.util.Optional; @@ -15,6 +17,19 @@ public interface PricingRegistry { */ Optional getPlan(String modelId); + /** + * 모델과 토큰 타입에 대한 가격 결정 결과를 조회합니다. + * 등록되지 않은 모델은 {@link io.tokenpilot.core.domain.PricingResolutionStatus#MISSING_PLAN}으로 표현합니다. + * @param modelId 모델 식별자 + * @param tokenType 토큰 타입 + * @return 가격 결정 결과 + */ + default PricingResolution resolveRate(String modelId, TokenType tokenType) { + return getPlan(modelId) + .map(plan -> plan.resolveRate(tokenType)) + .orElseGet(PricingResolution::missingPlan); + } + /** * 새로운 가격 정책을 등록하거나 업데이트합니다. * @param plan 가격 정책 diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java index fb962ca..4d55757 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java @@ -85,4 +85,17 @@ public BigDecimal getRate(TokenType type) { default -> rates.getOrDefault(type, BigDecimal.ZERO); }; } + + /** + * 특정 토큰 타입의 가격 결정 결과를 반환합니다. + * 명시적으로 등록된 0 rate는 {@link PricingResolutionStatus#RESOLVED}로, + * 누락된 rate는 {@link PricingResolutionStatus#MISSING_RATE}로 표현합니다. + */ + public PricingResolution resolveRate(TokenType type) { + if (rates.containsKey(type)) { + return PricingResolution.resolved(); + } + + return PricingResolution.missingRate(); + } } diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java new file mode 100644 index 0000000..80402e9 --- /dev/null +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java @@ -0,0 +1,74 @@ +package io.tokenpilot.core.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Currency; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class PricingPlanTest { + + @Test + @DisplayName("명시적으로 등록된 token type rate는 RESOLVED로 표현한다") + void resolveExplicitRate() { + PricingPlan plan = new PricingPlan( + "gpt-4o", + Map.of(TokenType.PROMPT, new BigDecimal("0.01")), + Currency.getInstance("USD") + ); + + PricingResolution resolution = plan.resolveRate(TokenType.PROMPT); + + assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.RESOLVED); + assertThat(resolution.isResolved()).isTrue(); + } + + @Test + @DisplayName("명시적으로 등록된 0 rate는 RESOLVED 무료 가격으로 표현한다") + void resolveExplicitZeroRate() { + PricingPlan plan = new PricingPlan( + "free-model", + Map.of(TokenType.PROMPT, BigDecimal.ZERO), + Currency.getInstance("USD") + ); + + PricingResolution resolution = plan.resolveRate(TokenType.PROMPT); + + assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.RESOLVED); + assertThat(resolution.isResolved()).isTrue(); + } + + @Test + @DisplayName("등록된 plan에 필요한 token type rate가 없으면 MISSING_RATE로 표현한다") + void resolveMissingRate() { + PricingPlan plan = new PricingPlan( + "prompt-only-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.01")), + Currency.getInstance("USD") + ); + + PricingResolution resolution = plan.resolveRate(TokenType.COMPLETION); + + assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.MISSING_RATE); + assertThat(resolution.isResolved()).isFalse(); + } + + @Test + @DisplayName("legacy getRate의 0 fallback과 resolveRate의 missing 표현은 구분된다") + void distinguishLegacyZeroFallbackFromMissingResolution() { + PricingPlan plan = new PricingPlan( + "prompt-only-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.01")), + Currency.getInstance("USD") + ); + + assertThat(plan.getRate(TokenType.COMPLETION)).isEqualByComparingTo(BigDecimal.ZERO); + + PricingResolution resolution = plan.resolveRate(TokenType.COMPLETION); + assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.MISSING_RATE); + assertThat(resolution.isResolved()).isFalse(); + } +} diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java index 8c27125..933fdac 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java @@ -1,6 +1,9 @@ package io.tokenpilot.core.internal; import io.tokenpilot.core.domain.PricingPlan; +import io.tokenpilot.core.domain.PricingResolution; +import io.tokenpilot.core.domain.PricingResolutionStatus; +import io.tokenpilot.core.domain.TokenType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -40,4 +43,13 @@ void shouldReturnEmptyWhenNotFound() { // Then assertThat(retrieved).isEmpty(); } -} \ No newline at end of file + + @Test + @DisplayName("등록되지 않은 모델의 가격 결정 결과는 MISSING_PLAN이어야 한다") + void shouldResolveMissingPlanWhenModelIsNotRegistered() { + PricingResolution resolution = registry.resolveRate("non-existent", TokenType.PROMPT); + + assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.MISSING_PLAN); + assertThat(resolution.isResolved()).isFalse(); + } +} From 774362c6d8a2558e1aab312ef38f773e0d3cab7c Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 09:35:18 +0900 Subject: [PATCH 03/32] =?UTF-8?q?refactor(core):=20=EA=B0=80=EA=B2=A9=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20enum?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/tokenpilot/core/PricingRegistry.java | 4 +- .../tokenpilot/core/domain/PricingPlan.java | 8 ++-- .../core/domain/PricingResolution.java | 33 +++----------- .../core/domain/PricingResolutionStatus.java | 8 ---- .../core/domain/PricingPlanTest.java | 8 ++-- .../core/domain/PricingResolutionTest.java | 45 ++++++++----------- .../internal/InMemoryPricingRegistryTest.java | 3 +- 7 files changed, 37 insertions(+), 72 deletions(-) delete mode 100644 token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolutionStatus.java diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java b/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java index 8165bc4..821c65f 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java @@ -19,7 +19,7 @@ public interface PricingRegistry { /** * 모델과 토큰 타입에 대한 가격 결정 결과를 조회합니다. - * 등록되지 않은 모델은 {@link io.tokenpilot.core.domain.PricingResolutionStatus#MISSING_PLAN}으로 표현합니다. + * 등록되지 않은 모델은 {@link PricingResolution#MISSING_PLAN}으로 표현합니다. * @param modelId 모델 식별자 * @param tokenType 토큰 타입 * @return 가격 결정 결과 @@ -27,7 +27,7 @@ public interface PricingRegistry { default PricingResolution resolveRate(String modelId, TokenType tokenType) { return getPlan(modelId) .map(plan -> plan.resolveRate(tokenType)) - .orElseGet(PricingResolution::missingPlan); + .orElse(PricingResolution.MISSING_PLAN); } /** diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java index 4d55757..9d8ff7d 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java @@ -88,14 +88,14 @@ public BigDecimal getRate(TokenType type) { /** * 특정 토큰 타입의 가격 결정 결과를 반환합니다. - * 명시적으로 등록된 0 rate는 {@link PricingResolutionStatus#RESOLVED}로, - * 누락된 rate는 {@link PricingResolutionStatus#MISSING_RATE}로 표현합니다. + * 명시적으로 등록된 0 rate는 {@link PricingResolution#RESOLVED}로, + * 누락된 rate는 {@link PricingResolution#MISSING_RATE}로 표현합니다. */ public PricingResolution resolveRate(TokenType type) { if (rates.containsKey(type)) { - return PricingResolution.resolved(); + return PricingResolution.RESOLVED; } - return PricingResolution.missingRate(); + return PricingResolution.MISSING_RATE; } } diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolution.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolution.java index e48d233..2a6b6fe 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolution.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolution.java @@ -1,31 +1,12 @@ package io.tokenpilot.core.domain; -import java.util.Objects; - -public record PricingResolution( - PricingResolutionStatus status -) { - public PricingResolution { - Objects.requireNonNull(status, "status must not be null"); - } - - public static PricingResolution resolved() { - return new PricingResolution(PricingResolutionStatus.RESOLVED); - } - - public static PricingResolution missingPlan() { - return new PricingResolution(PricingResolutionStatus.MISSING_PLAN); - } - - public static PricingResolution missingRate() { - return new PricingResolution(PricingResolutionStatus.MISSING_RATE); - } - - public static PricingResolution currencyMismatch() { - return new PricingResolution(PricingResolutionStatus.CURRENCY_MISMATCH); - } +public enum PricingResolution { + RESOLVED, + MISSING_PLAN, + MISSING_RATE, + CURRENCY_MISMATCH; public boolean isResolved() { - return status == PricingResolutionStatus.RESOLVED; + return this == RESOLVED; } -} \ No newline at end of file +} diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolutionStatus.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolutionStatus.java deleted file mode 100644 index 03fd0bd..0000000 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingResolutionStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -package io.tokenpilot.core.domain; - -public enum PricingResolutionStatus { - RESOLVED, - MISSING_PLAN, - MISSING_RATE, - CURRENCY_MISMATCH -} \ No newline at end of file diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java index 80402e9..e865ed7 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java @@ -22,7 +22,7 @@ void resolveExplicitRate() { PricingResolution resolution = plan.resolveRate(TokenType.PROMPT); - assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.RESOLVED); + assertThat(resolution).isEqualTo(PricingResolution.RESOLVED); assertThat(resolution.isResolved()).isTrue(); } @@ -37,7 +37,7 @@ void resolveExplicitZeroRate() { PricingResolution resolution = plan.resolveRate(TokenType.PROMPT); - assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.RESOLVED); + assertThat(resolution).isEqualTo(PricingResolution.RESOLVED); assertThat(resolution.isResolved()).isTrue(); } @@ -52,7 +52,7 @@ void resolveMissingRate() { PricingResolution resolution = plan.resolveRate(TokenType.COMPLETION); - assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.MISSING_RATE); + assertThat(resolution).isEqualTo(PricingResolution.MISSING_RATE); assertThat(resolution.isResolved()).isFalse(); } @@ -68,7 +68,7 @@ void distinguishLegacyZeroFallbackFromMissingResolution() { assertThat(plan.getRate(TokenType.COMPLETION)).isEqualByComparingTo(BigDecimal.ZERO); PricingResolution resolution = plan.resolveRate(TokenType.COMPLETION); - assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.MISSING_RATE); + assertThat(resolution).isEqualTo(PricingResolution.MISSING_RATE); assertThat(resolution.isResolved()).isFalse(); } } diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java index 6fd595e..a548829 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java @@ -4,71 +4,64 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; class PricingResolutionTest { @Test - @DisplayName("PricingResolutionStatus는 public API에서 bounded 상태값을 제공한다") - void exposesPricingResolutionStatuses() { - assertThat(PricingResolutionStatus.values()) + @DisplayName("PricingResolution은 public API에서 bounded 상태값을 제공한다") + void exposesPricingResolutionValues() { + assertThat(PricingResolution.values()) .containsExactly( - PricingResolutionStatus.RESOLVED, - PricingResolutionStatus.MISSING_PLAN, - PricingResolutionStatus.MISSING_RATE, - PricingResolutionStatus.CURRENCY_MISMATCH + PricingResolution.RESOLVED, + PricingResolution.MISSING_PLAN, + PricingResolution.MISSING_RATE, + PricingResolution.CURRENCY_MISMATCH ); } @Test @DisplayName("RESOLVED 결과는 성공 상태로 표현된다") void resolvedIsSuccessful() { - PricingResolution resolution = PricingResolution.resolved(); + PricingResolution resolution = PricingResolution.RESOLVED; - assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.RESOLVED); assertThat(resolution.isResolved()).isTrue(); } @Test @DisplayName("MISSING_PLAN 결과는 실패 상태로 표현된다") void missingPlanIsNotResolved() { - PricingResolution resolution = PricingResolution.missingPlan(); + PricingResolution resolution = PricingResolution.MISSING_PLAN; - assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.MISSING_PLAN); assertThat(resolution.isResolved()).isFalse(); } @Test @DisplayName("MISSING_RATE 결과는 실패 상태로 표현된다") void missingRateIsNotResolved() { - PricingResolution resolution = PricingResolution.missingRate(); + PricingResolution resolution = PricingResolution.MISSING_RATE; - assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.MISSING_RATE); assertThat(resolution.isResolved()).isFalse(); } @Test @DisplayName("CURRENCY_MISMATCH 결과는 실패 상태로 표현된다") void currencyMismatchIsNotResolved() { - PricingResolution resolution = PricingResolution.currencyMismatch(); + PricingResolution resolution = PricingResolution.CURRENCY_MISMATCH; - assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.CURRENCY_MISMATCH); assertThat(resolution.isResolved()).isFalse(); } @Test - @DisplayName("PricingResolution은 null status를 허용하지 않는다") - void rejectsNullStatus() { - assertThatThrownBy(() -> new PricingResolution(null)) - .isInstanceOf(NullPointerException.class) - .hasMessage("status must not be null"); + @DisplayName("PricingResolution은 별도 payload 없이 상태 자체로 표현된다") + void resolutionItselfIsState() { + assertThat(PricingResolution.RESOLVED.name()).isEqualTo("RESOLVED"); } @Test - @DisplayName("PricingResolutionStatus 자체가 low-cardinality pricing miss reason이다") - void statusItselfIsLowCardinalityReason() { - assertThat(PricingResolutionStatus.MISSING_PLAN.name()).isEqualTo("MISSING_PLAN"); - assertThat(PricingResolutionStatus.MISSING_RATE.name()).isEqualTo("MISSING_RATE"); - assertThat(PricingResolutionStatus.CURRENCY_MISMATCH.name()).isEqualTo("CURRENCY_MISMATCH"); + @DisplayName("PricingResolution 자체가 low-cardinality pricing miss reason이다") + void resolutionItselfIsLowCardinalityReason() { + assertThat(PricingResolution.MISSING_PLAN.name()).isEqualTo("MISSING_PLAN"); + assertThat(PricingResolution.MISSING_RATE.name()).isEqualTo("MISSING_RATE"); + assertThat(PricingResolution.CURRENCY_MISMATCH.name()).isEqualTo("CURRENCY_MISMATCH"); } } diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java index 933fdac..77bc4c2 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java @@ -2,7 +2,6 @@ import io.tokenpilot.core.domain.PricingPlan; import io.tokenpilot.core.domain.PricingResolution; -import io.tokenpilot.core.domain.PricingResolutionStatus; import io.tokenpilot.core.domain.TokenType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -49,7 +48,7 @@ void shouldReturnEmptyWhenNotFound() { void shouldResolveMissingPlanWhenModelIsNotRegistered() { PricingResolution resolution = registry.resolveRate("non-existent", TokenType.PROMPT); - assertThat(resolution.status()).isEqualTo(PricingResolutionStatus.MISSING_PLAN); + assertThat(resolution).isEqualTo(PricingResolution.MISSING_PLAN); assertThat(resolution.isResolved()).isFalse(); } } From 8381927262a148bb65a49d5562e16745f279c2f1 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 10:02:27 +0900 Subject: [PATCH 04/32] =?UTF-8?q?feat(core):=20fallback=20rate=EB=8A=94=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20token=20type=20rate=EA=B0=80=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=EB=A1=9C=20=EB=93=B1=EB=A1=9D=EB=90=9C=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=EC=97=90=EB=A7=8C=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tokenpilot/core/domain/PricingPlan.java | 17 ++-- .../core/domain/PricingRateFallback.java | 27 ++++++ .../core/domain/PricingPlanTest.java | 83 +++++++++++++++++++ 3 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingRateFallback.java diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java index 9d8ff7d..c0397f3 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java @@ -24,7 +24,7 @@ public record PricingPlan( if (currency == null) { currency = Currency.getInstance("USD"); } - // 모든 단가는 0 이상이어야 함 + rates.values().forEach(v -> { if (v.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("Price cannot be negative"); @@ -77,13 +77,9 @@ public BigDecimal getRate(TokenType type) { return rates.get(type); } - // Fallback Logic - return switch (type) { - case REASONING -> rates.getOrDefault(TokenType.COMPLETION, BigDecimal.ZERO); - case CACHE_READ_PROMPT, CACHE_CREATION_PROMPT -> - rates.getOrDefault(TokenType.PROMPT, BigDecimal.ZERO); - default -> rates.getOrDefault(type, BigDecimal.ZERO); - }; + return PricingRateFallback.fallbackFor(type) + .map(fallbackType -> rates.getOrDefault(fallbackType, BigDecimal.ZERO)) + .orElse(BigDecimal.ZERO); } /** @@ -96,6 +92,9 @@ public PricingResolution resolveRate(TokenType type) { return PricingResolution.RESOLVED; } - return PricingResolution.MISSING_RATE; + return PricingRateFallback.fallbackFor(type) + .filter(rates::containsKey) + .map(fallbackType -> PricingResolution.RESOLVED) + .orElse(PricingResolution.MISSING_RATE); } } diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingRateFallback.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingRateFallback.java new file mode 100644 index 0000000..2a57332 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingRateFallback.java @@ -0,0 +1,27 @@ +package io.tokenpilot.core.domain; + +import java.util.Optional; + +enum PricingRateFallback { + REASONING_TO_COMPLETION(TokenType.REASONING, TokenType.COMPLETION), + CACHE_READ_PROMPT_TO_PROMPT(TokenType.CACHE_READ_PROMPT, TokenType.PROMPT), + CACHE_CREATION_PROMPT_TO_PROMPT(TokenType.CACHE_CREATION_PROMPT, TokenType.PROMPT); + + private final TokenType tokenType; + private final TokenType fallbackTokenType; + + PricingRateFallback(TokenType tokenType, TokenType fallbackTokenType) { + this.tokenType = tokenType; + this.fallbackTokenType = fallbackTokenType; + } + + static Optional fallbackFor(TokenType tokenType) { + for (PricingRateFallback fallback : values()) { + if (fallback.tokenType == tokenType) { + return Optional.of(fallback.fallbackTokenType); + } + } + + return Optional.empty(); + } +} diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java index e865ed7..27974b9 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java @@ -71,4 +71,87 @@ void distinguishLegacyZeroFallbackFromMissingResolution() { assertThat(resolution).isEqualTo(PricingResolution.MISSING_RATE); assertThat(resolution.isResolved()).isFalse(); } + + @Test + @DisplayName("COMPLETION rate가 명시적으로 있으면 REASONING fallback은 RESOLVED다") + void resolveReasoningFallbackFromExplicitCompletionRate() { + PricingPlan plan = new PricingPlan( + "completion-model", + Map.of(TokenType.COMPLETION, new BigDecimal("0.03")), + Currency.getInstance("USD") + ); + + PricingResolution resolution = plan.resolveRate(TokenType.REASONING); + + assertThat(resolution).isEqualTo(PricingResolution.RESOLVED); + assertThat(resolution.isResolved()).isTrue(); + } + + @Test + @DisplayName("COMPLETION rate가 없으면 REASONING fallback은 MISSING_RATE다") + void missingReasoningFallbackWithoutCompletionRate() { + PricingPlan plan = new PricingPlan( + "prompt-only-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.01")), + Currency.getInstance("USD") + ); + + PricingResolution resolution = plan.resolveRate(TokenType.REASONING); + + assertThat(resolution).isEqualTo(PricingResolution.MISSING_RATE); + assertThat(resolution.isResolved()).isFalse(); + } + + @Test + @DisplayName("PROMPT rate가 명시적으로 있으면 cache token type fallback은 RESOLVED다") + void resolveCacheFallbackFromExplicitPromptRate() { + PricingPlan plan = new PricingPlan( + "prompt-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.01")), + Currency.getInstance("USD") + ); + + PricingResolution readResolution = plan.resolveRate(TokenType.CACHE_READ_PROMPT); + PricingResolution creationResolution = plan.resolveRate(TokenType.CACHE_CREATION_PROMPT); + + assertThat(readResolution).isEqualTo(PricingResolution.RESOLVED); + assertThat(readResolution.isResolved()).isTrue(); + assertThat(creationResolution).isEqualTo(PricingResolution.RESOLVED); + assertThat(creationResolution.isResolved()).isTrue(); + } + + @Test + @DisplayName("PROMPT rate가 없으면 cache token type fallback은 MISSING_RATE다") + void missingCacheFallbackWithoutPromptRate() { + PricingPlan plan = new PricingPlan( + "completion-only-model", + Map.of(TokenType.COMPLETION, new BigDecimal("0.03")), + Currency.getInstance("USD") + ); + + PricingResolution readResolution = plan.resolveRate(TokenType.CACHE_READ_PROMPT); + PricingResolution creationResolution = plan.resolveRate(TokenType.CACHE_CREATION_PROMPT); + + assertThat(readResolution).isEqualTo(PricingResolution.MISSING_RATE); + assertThat(readResolution.isResolved()).isFalse(); + assertThat(creationResolution).isEqualTo(PricingResolution.MISSING_RATE); + assertThat(creationResolution.isResolved()).isFalse(); + } + + @Test + @DisplayName("fallback 기준 rate가 0으로 명시되어 있으면 RESOLVED다") + void resolveFallbackFromExplicitZeroBaseRate() { + PricingPlan plan = new PricingPlan( + "zero-fallback-model", + Map.of( + TokenType.PROMPT, BigDecimal.ZERO, + TokenType.COMPLETION, BigDecimal.ZERO + ), + Currency.getInstance("USD") + ); + + assertThat(plan.resolveRate(TokenType.REASONING)).isEqualTo(PricingResolution.RESOLVED); + assertThat(plan.resolveRate(TokenType.CACHE_READ_PROMPT)).isEqualTo(PricingResolution.RESOLVED); + assertThat(plan.resolveRate(TokenType.CACHE_CREATION_PROMPT)).isEqualTo(PricingResolution.RESOLVED); + } } From ab896d61e02a11302667a62c4da4da1db074181c Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 10:07:58 +0900 Subject: [PATCH 05/32] =?UTF-8?q?test(core):=20PricingRegistry=20=EA=B0=80?= =?UTF-8?q?=EA=B2=A9=20=EA=B2=B0=EC=A0=95=20=EC=9C=84=EC=9E=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/InMemoryPricingRegistryTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java index 77bc4c2..206b777 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java @@ -8,6 +8,7 @@ import java.math.BigDecimal; import java.util.Currency; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -51,4 +52,20 @@ void shouldResolveMissingPlanWhenModelIsNotRegistered() { assertThat(resolution).isEqualTo(PricingResolution.MISSING_PLAN); assertThat(resolution.isResolved()).isFalse(); } + + @Test + @DisplayName("등록된 모델의 가격 결정 결과는 PricingPlan resolveRate 결과를 따라야 한다") + void shouldDelegateRateResolutionToRegisteredPlan() { + PricingPlan plan = new PricingPlan( + "prompt-only-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.015")), + Currency.getInstance("USD") + ); + registry.registerPlan(plan); + + assertThat(registry.resolveRate("prompt-only-model", TokenType.PROMPT)) + .isEqualTo(PricingResolution.RESOLVED); + assertThat(registry.resolveRate("prompt-only-model", TokenType.COMPLETION)) + .isEqualTo(PricingResolution.MISSING_RATE); + } } From d1f0021a6b8fc6811cdf9f12bbf672ad8eeefda4 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 10:22:12 +0900 Subject: [PATCH 06/32] =?UTF-8?q?feat(core):=20=EA=B8=B0=EB=8C=80=20?= =?UTF-8?q?=ED=86=B5=ED=99=94=EC=99=80=20pricing=20plan=20=ED=86=B5?= =?UTF-8?q?=ED=99=94=EA=B0=80=20=EB=8B=A4=EB=A5=B4=EB=A9=B4=20=EA=B0=80?= =?UTF-8?q?=EA=B2=A9=EC=9D=B4=20resolved=EB=90=9C=20=EA=B2=83=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=B7=A8=EA=B8=89=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/tokenpilot/core/PricingRegistry.java | 17 ++++-- .../internal/InMemoryPricingRegistry.java | 26 ++++++++ .../internal/InMemoryPricingRegistryTest.java | 61 +++++++++++++++++++ 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java b/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java index 821c65f..5cf04f1 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java @@ -4,6 +4,7 @@ import io.tokenpilot.core.domain.PricingResolution; import io.tokenpilot.core.domain.TokenType; +import java.util.Currency; import java.util.Optional; /** @@ -19,16 +20,20 @@ public interface PricingRegistry { /** * 모델과 토큰 타입에 대한 가격 결정 결과를 조회합니다. - * 등록되지 않은 모델은 {@link PricingResolution#MISSING_PLAN}으로 표현합니다. * @param modelId 모델 식별자 * @param tokenType 토큰 타입 * @return 가격 결정 결과 */ - default PricingResolution resolveRate(String modelId, TokenType tokenType) { - return getPlan(modelId) - .map(plan -> plan.resolveRate(tokenType)) - .orElse(PricingResolution.MISSING_PLAN); - } + PricingResolution resolveRate(String modelId, TokenType tokenType); + + /** + * 모델과 토큰 타입에 대한 가격 결정 결과를 기대 통화 기준으로 조회합니다. + * @param modelId 모델 식별자 + * @param tokenType 토큰 타입 + * @param expectedCurrency 기대 통화 + * @return 가격 결정 결과 + */ + PricingResolution resolveRate(String modelId, TokenType tokenType, Currency expectedCurrency); /** * 새로운 가격 정책을 등록하거나 업데이트합니다. diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/InMemoryPricingRegistry.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/InMemoryPricingRegistry.java index 5dccfee..3dc8e29 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/InMemoryPricingRegistry.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/InMemoryPricingRegistry.java @@ -3,10 +3,14 @@ import io.tokenpilot.core.PricingRegistry; import io.tokenpilot.core.PricingProvider; import io.tokenpilot.core.domain.PricingPlan; +import io.tokenpilot.core.domain.PricingResolution; +import io.tokenpilot.core.domain.TokenType; import java.util.Collection; +import java.util.Currency; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -34,6 +38,28 @@ public Optional getPlan(String modelId) { return Optional.ofNullable(plans.get(modelId)); } + @Override + public PricingResolution resolveRate(String modelId, TokenType tokenType) { + return getPlan(modelId) + .map(plan -> plan.resolveRate(tokenType)) + .orElse(PricingResolution.MISSING_PLAN); + } + + @Override + public PricingResolution resolveRate(String modelId, TokenType tokenType, Currency expectedCurrency) { + Objects.requireNonNull(expectedCurrency, "expectedCurrency must not be null"); + + return getPlan(modelId) + .map(plan -> { + if (!plan.currency().equals(expectedCurrency)) { + return PricingResolution.CURRENCY_MISMATCH; + } + + return plan.resolveRate(tokenType); + }) + .orElse(PricingResolution.MISSING_PLAN); + } + @Override public void registerPlan(PricingPlan plan) { plans.put(plan.modelId(), plan); diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java index 206b777..925fb59 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java @@ -12,6 +12,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class InMemoryPricingRegistryTest { @@ -68,4 +69,64 @@ void shouldDelegateRateResolutionToRegisteredPlan() { assertThat(registry.resolveRate("prompt-only-model", TokenType.COMPLETION)) .isEqualTo(PricingResolution.MISSING_RATE); } + + @Test + @DisplayName("기대 통화와 plan 통화가 다르면 CURRENCY_MISMATCH여야 한다") + void shouldResolveCurrencyMismatchWhenExpectedCurrencyDiffers() { + PricingPlan plan = new PricingPlan( + "usd-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.015")), + Currency.getInstance("USD") + ); + registry.registerPlan(plan); + + PricingResolution resolution = registry.resolveRate( + "usd-model", + TokenType.PROMPT, + Currency.getInstance("KRW") + ); + + assertThat(resolution).isEqualTo(PricingResolution.CURRENCY_MISMATCH); + assertThat(resolution.isResolved()).isFalse(); + } + + @Test + @DisplayName("기대 통화와 plan 통화가 같으면 일반 rate resolution을 수행해야 한다") + void shouldDelegateRateResolutionWhenExpectedCurrencyMatches() { + PricingPlan plan = new PricingPlan( + "usd-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.015")), + Currency.getInstance("USD") + ); + registry.registerPlan(plan); + + assertThat(registry.resolveRate("usd-model", TokenType.PROMPT, Currency.getInstance("USD"))) + .isEqualTo(PricingResolution.RESOLVED); + assertThat(registry.resolveRate("usd-model", TokenType.COMPLETION, Currency.getInstance("USD"))) + .isEqualTo(PricingResolution.MISSING_RATE); + } + + @Test + @DisplayName("기대 통화가 없는 경로는 통화 검사를 수행하지 않고 일반 rate resolution을 수행해야 한다") + void shouldSkipCurrencyCheckWhenExpectedCurrencyIsNotProvided() { + PricingPlan plan = new PricingPlan( + "krw-model", + Map.of(TokenType.PROMPT, new BigDecimal("15")), + Currency.getInstance("KRW") + ); + registry.registerPlan(plan); + + assertThat(registry.resolveRate("krw-model", TokenType.PROMPT)) + .isEqualTo(PricingResolution.RESOLVED); + assertThat(registry.resolveRate("krw-model", TokenType.COMPLETION)) + .isEqualTo(PricingResolution.MISSING_RATE); + } + + @Test + @DisplayName("기대 통화가 있는 경로는 null expectedCurrency를 허용하지 않는다") + void shouldRejectNullExpectedCurrency() { + assertThatThrownBy(() -> registry.resolveRate("any-model", TokenType.PROMPT, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("expectedCurrency must not be null"); + } } From 9b4f5924c10bcac978aa740bb3b5b1ee5c32b260 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 12:34:31 +0900 Subject: [PATCH 07/32] =?UTF-8?q?feat(core):=20MissingPricingPolicy=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/domain/MissingPricingPolicy.java | 6 +++++ .../core/domain/MissingPricingPolicyTest.java | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 token-pilot-core/src/main/java/io/tokenpilot/core/domain/MissingPricingPolicy.java create mode 100644 token-pilot-core/src/test/java/io/tokenpilot/core/domain/MissingPricingPolicyTest.java diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/MissingPricingPolicy.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/MissingPricingPolicy.java new file mode 100644 index 0000000..3c46bcc --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/MissingPricingPolicy.java @@ -0,0 +1,6 @@ +package io.tokenpilot.core.domain; + +public enum MissingPricingPolicy { + FAIL_OPEN, + FAIL_CLOSED +} diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/MissingPricingPolicyTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/MissingPricingPolicyTest.java new file mode 100644 index 0000000..f897c1b --- /dev/null +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/MissingPricingPolicyTest.java @@ -0,0 +1,27 @@ +package io.tokenpilot.core.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MissingPricingPolicyTest { + + @Test + @DisplayName("MissingPricingPolicy는 FAIL_OPEN과 FAIL_CLOSED를 제공한다") + void exposesMissingPricingPolicies() { + assertThat(MissingPricingPolicy.values()) + .containsExactly( + MissingPricingPolicy.FAIL_OPEN, + MissingPricingPolicy.FAIL_CLOSED + ); + } + + @Test + @DisplayName("PricingResolution은 pricing 상태만 표현한다") + void pricingResolutionDoesNotIncludePolicyStates() { + assertThat(PricingResolution.values()) + .extracting(Enum::name) + .doesNotContain("FAIL_OPEN", "FAIL_CLOSED"); + } +} From f8b13b9f45715b87d65b354e1c12103e9c1fd008 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 13:22:46 +0900 Subject: [PATCH 08/32] =?UTF-8?q?feat(core):=20ModelRegistry=20alias?= =?UTF-8?q?=EB=8A=94=20canonical=20model=20id=EB=A5=BC=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/internal/InMemoryPricingRegistryTest.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java index 925fb59..7cb6dae 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java @@ -21,17 +21,15 @@ class InMemoryPricingRegistryTest { @Test @DisplayName("가격 정책을 등록하고 모델 ID로 조회할 수 있어야 한다") void shouldRegisterAndGetPlan() { - // Given - PricingPlan plan = new PricingPlan("claude-3", + String modelId = "claude-3-5-sonnet-20241022"; + PricingPlan plan = new PricingPlan(modelId, new BigDecimal("0.015"), new BigDecimal("0.075"), Currency.getInstance("USD")); - // When registry.registerPlan(plan); - Optional retrieved = registry.getPlan("claude-3"); + Optional retrieved = registry.getPlan(modelId); - // Then assertThat(retrieved).isPresent(); - assertThat(retrieved.get().modelId()).isEqualTo("claude-3"); + assertThat(retrieved.get().modelId()).isEqualTo(modelId); assertThat(retrieved.get().promptPricePerK()).isEqualByComparingTo("0.015"); } From 93289e4740c033f56b552d7ee0b2763230493a61 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 13:39:22 +0900 Subject: [PATCH 09/32] =?UTF-8?q?feat(core):=20pricing=20lookup=EC=9D=80?= =?UTF-8?q?=20model=20id=EC=99=80=20pricing=20policy=20id=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokenPilotAutoConfigurationTest.java | 5 +++ .../io/tokenpilot/core/PricingRegistry.java | 8 ++++ .../tokenpilot/core/domain/PricingPlan.java | 20 ++++++++- .../internal/InMemoryPricingRegistry.java | 14 ++++-- .../internal/InMemoryPricingRegistryTest.java | 44 +++++++++++++++++++ 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java index b689fa9..b3da9e0 100644 --- a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java +++ b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java @@ -15,6 +15,8 @@ import io.tokenpilot.core.PricingRegistry; import io.tokenpilot.core.domain.Cost; import io.tokenpilot.core.domain.PricingPlan; +import io.tokenpilot.core.domain.PricingResolution; +import io.tokenpilot.core.domain.TokenType; import io.tokenpilot.core.domain.TokenUsage; import io.tokenpilot.notification.BudgetNotificationHandler; import io.tokenpilot.notification.BudgetNotificationService; @@ -378,6 +380,9 @@ public PricingRegistry pricingRegistry() { static class UserCustomPricingRegistry implements PricingRegistry { @Override public void registerPlan(PricingPlan plan) {} @Override public Optional getPlan(String modelId) { return Optional.empty(); } + @Override public Optional getPlan(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) diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java b/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java index 5cf04f1..8aa3a9b 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java @@ -18,6 +18,14 @@ public interface PricingRegistry { */ Optional getPlan(String modelId); + /** + * 모델 식별자와 pricing policy id로 등록된 가격 정책을 조회합니다. + * @param modelId 모델 식별자 + * @param pricingPolicyId pricing policy 식별자 + * @return 가격 정책 (존재하지 않을 경우 empty) + */ + Optional getPlan(String modelId, String pricingPolicyId); + /** * 모델과 토큰 타입에 대한 가격 결정 결과를 조회합니다. * @param modelId 모델 식별자 diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java index c0397f3..cddefbc 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java @@ -16,10 +16,17 @@ */ public record PricingPlan( String modelId, + String pricingPolicyId, Map rates, Currency currency ) { + public static final String DEFAULT_PRICING_POLICY_ID = "default"; + public PricingPlan { + if (pricingPolicyId == null || pricingPolicyId.isBlank()) { + throw new IllegalArgumentException("pricingPolicyId must not be blank"); + } + rates = Collections.unmodifiableMap(new EnumMap<>(rates)); if (currency == null) { currency = Currency.getInstance("USD"); @@ -32,11 +39,22 @@ public record PricingPlan( }); } + public PricingPlan(String modelId, Map rates, Currency currency) { + this(modelId, DEFAULT_PRICING_POLICY_ID, rates, currency); + } + /** * 기본 입력/출력 단가와 통화를 사용하는 {@link PricingPlan}을 생성합니다. */ public PricingPlan(String modelId, BigDecimal promptPricePerK, BigDecimal completionPricePerK, Currency currency) { - this(modelId, createRates(promptPricePerK, completionPricePerK), currency); + this(modelId, DEFAULT_PRICING_POLICY_ID, createRates(promptPricePerK, completionPricePerK), currency); + } + + /** + * 기본 입력/출력 단가와 pricing policy id, 통화를 사용하는 {@link PricingPlan}을 생성합니다. + */ + public PricingPlan(String modelId, String pricingPolicyId, BigDecimal promptPricePerK, BigDecimal completionPricePerK, Currency currency) { + this(modelId, pricingPolicyId, createRates(promptPricePerK, completionPricePerK), currency); } /** diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/InMemoryPricingRegistry.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/InMemoryPricingRegistry.java index 3dc8e29..78f17e3 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/InMemoryPricingRegistry.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/InMemoryPricingRegistry.java @@ -19,7 +19,7 @@ * 메모리 기반 가격 정책 저장소 구현체. */ class InMemoryPricingRegistry implements PricingRegistry { - private final Map plans = new ConcurrentHashMap<>(); + private final Map plans = new ConcurrentHashMap<>(); public InMemoryPricingRegistry() { } @@ -35,7 +35,12 @@ public InMemoryPricingRegistry(List providers) { @Override public Optional getPlan(String modelId) { - return Optional.ofNullable(plans.get(modelId)); + return getPlan(modelId, PricingPlan.DEFAULT_PRICING_POLICY_ID); + } + + @Override + public Optional getPlan(String modelId, String pricingPolicyId) { + return Optional.ofNullable(plans.get(new PricingPlanKey(modelId, pricingPolicyId))); } @Override @@ -62,6 +67,9 @@ public PricingResolution resolveRate(String modelId, TokenType tokenType, Curren @Override public void registerPlan(PricingPlan plan) { - plans.put(plan.modelId(), plan); + plans.put(new PricingPlanKey(plan.modelId(), plan.pricingPolicyId()), plan); + } + + private record PricingPlanKey(String modelId, String pricingPolicyId) { } } diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java index 7cb6dae..5288f93 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java @@ -33,6 +33,50 @@ void shouldRegisterAndGetPlan() { assertThat(retrieved.get().promptPricePerK()).isEqualByComparingTo("0.015"); } + @Test + @DisplayName("모델 ID와 pricing policy ID로 가격 정책을 조회할 수 있어야 한다") + void shouldRegisterAndGetPlanByModelIdAndPricingPolicyId() { + String modelId = "gpt-4o-2024-08-06"; + String pricingPolicyId = "openai-gpt-4o-2024-08-06-standard"; + PricingPlan plan = new PricingPlan( + modelId, + pricingPolicyId, + Map.of(TokenType.PROMPT, new BigDecimal("0.0025")), + Currency.getInstance("USD") + ); + + registry.registerPlan(plan); + Optional retrieved = registry.getPlan(modelId, pricingPolicyId); + + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().modelId()).isEqualTo(modelId); + assertThat(retrieved.get().pricingPolicyId()).isEqualTo(pricingPolicyId); + } + + @Test + @DisplayName("동일 모델 ID라도 pricing policy ID가 다르면 다른 가격 정책으로 조회되어야 한다") + void shouldDistinguishPlansByPricingPolicyId() { + String modelId = "gpt-4o-2024-08-06"; + PricingPlan standardPlan = new PricingPlan( + modelId, + "standard", + Map.of(TokenType.PROMPT, new BigDecimal("0.0025")), + Currency.getInstance("USD") + ); + PricingPlan discountedPlan = new PricingPlan( + modelId, + "discounted", + Map.of(TokenType.PROMPT, new BigDecimal("0.0010")), + Currency.getInstance("USD") + ); + + registry.registerPlan(standardPlan); + registry.registerPlan(discountedPlan); + + assertThat(registry.getPlan(modelId, "standard")).contains(standardPlan); + assertThat(registry.getPlan(modelId, "discounted")).contains(discountedPlan); + } + @Test @DisplayName("등록되지 않은 모델 조회 시 빈 Optional을 반환해야 한다") void shouldReturnEmptyWhenNotFound() { From 9f773db508646a6f192cb86cc6c1d66e69c9eb0b Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 13:53:46 +0900 Subject: [PATCH 10/32] =?UTF-8?q?feat(core):=20provider=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=A0=84=EC=97=90=20pricing=20plan=EC=9D=84=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88=20resolve=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/tokenpilot/core/LedgerManager.java | 10 ++ .../core/internal/DefaultLedgerManager.java | 15 ++- .../internal/DefaultLedgerManagerTest.java | 26 ++++- .../internal/DefaultLedgerAdvisor.java | 98 +++++++++++++--- .../internal/DefaultLedgerAdvisorTest.java | 109 +++++++++++++++++- 5 files changed, 235 insertions(+), 23 deletions(-) diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/LedgerManager.java b/token-pilot-core/src/main/java/io/tokenpilot/core/LedgerManager.java index 271e8a9..796924e 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/LedgerManager.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/LedgerManager.java @@ -1,6 +1,7 @@ package io.tokenpilot.core; import io.tokenpilot.core.domain.Cost; + import io.tokenpilot.core.domain.PricingPlan; import io.tokenpilot.core.domain.TokenUsage; import java.util.Map; @@ -17,4 +18,13 @@ public interface LedgerManager { * @return 산출된 비용 */ Cost record(String modelId, TokenUsage usage, Map tags); + + /** + * 이미 resolve된 가격 정책으로 호출 정보를 기록하고 최종 비용을 계산합니다. + * @param plan provider 호출 전에 resolve된 가격 정책 + * @param usage 토큰 사용량 + * @param tags 추가 메타데이터 (tenant_id, user_id 등) + * @return 산출된 비용 + */ + Cost record(PricingPlan plan, TokenUsage usage, Map tags); } diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java index 28bd66c..3d4a589 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java @@ -49,12 +49,23 @@ public Cost record(String modelId, TokenUsage usage, Map tags) { .map(plan -> costCalculator.calculate(usage, plan)) .orElse(Cost.zero(UNPRICED_COST_CURRENCY)); + publish(modelId, usage, cost, tags); + + return cost; + } + + @Override + public Cost record(PricingPlan plan, TokenUsage usage, Map tags) { + Cost cost = costCalculator.calculate(usage, plan); + publish(plan.modelId(), usage, cost, tags); + return cost; + } + + private void publish(String modelId, TokenUsage usage, Cost cost, Map tags) { // 이벤트 발행 (리스너들에게 전파) if (!listeners.isEmpty()) { CostRecordedEvent event = new CostRecordedEvent(modelId, usage, cost, tags); listeners.forEach(listener -> listener.onRecord(event)); } - - return cost; } } diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultLedgerManagerTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultLedgerManagerTest.java index 2773979..7ae30ee 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultLedgerManagerTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultLedgerManagerTest.java @@ -14,7 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; class DefaultLedgerManagerTest { @@ -58,4 +58,28 @@ void shouldReturnZeroCostWhenPlanIsMissing() { assertThat(result.currency()).isEqualTo(Currency.getInstance("USD")); verify(listener).onRecord(any(CostRecordedEvent.class)); } + + @Test + @DisplayName("이미 resolve된 plan으로 기록하면 registry를 다시 조회하지 않아야 한다") + void shouldRecordWithResolvedPlanWithoutRegistryLookup() { + PricingRegistry pricingRegistry = Mockito.mock(PricingRegistry.class); + CostCalculator costCalculator = Mockito.mock(CostCalculator.class); + LedgerListener listener = Mockito.mock(LedgerListener.class); + DefaultLedgerManager manager = new DefaultLedgerManager(pricingRegistry, costCalculator, List.of(listener)); + PricingPlan plan = new PricingPlan("gpt-4o", new BigDecimal("5.0"), new BigDecimal("15.0")); + TokenUsage usage = TokenUsage.from(1000, 1000); + Cost expectedCost = new Cost(new BigDecimal("20.000000"), Currency.getInstance("USD")); + + when(costCalculator.calculate(usage, plan)).thenReturn(expectedCost); + + Cost cost = manager.record(plan, usage, Map.of()); + + assertThat(cost).isEqualTo(expectedCost); + verifyNoInteractions(pricingRegistry); + verify(listener).onRecord(argThat(event -> + event.modelId().equals("gpt-4o") && + event.usage().equals(usage) && + event.cost().equals(expectedCost) + )); + } } diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index c80a808..8cd4b71 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -25,12 +25,15 @@ public class DefaultLedgerAdvisor implements LedgerAdvisor { static final String BUDGET_DECISION_CONTEXT = "tokenpilot.budget.decision"; + static final String MODEL_ID_CONTEXT = "tokenpilot.model.id"; + static final String PRICING_POLICY_ID_CONTEXT = "tokenpilot.pricing.policy.id"; + static final String PRICING_PLAN_CONTEXT = "tokenpilot.pricing.plan"; + static final String PRICING_RESOLUTION_CONTEXT = "tokenpilot.pricing.resolution"; private final LedgerManager ledgerManager; private final UsageExtractor usageExtractor; private final BudgetEvaluator budgetEvaluator; private final BudgetStateStore budgetStateStore; - private final CostCalculator costCalculator; private final PricingRegistry pricingRegistry; public DefaultLedgerAdvisor(LedgerManager ledgerManager, UsageExtractor usageExtractor) { @@ -44,20 +47,26 @@ public DefaultLedgerAdvisor(LedgerManager ledgerManager, UsageExtractor usageExt this.usageExtractor = usageExtractor; this.budgetEvaluator = budgetEvaluator; this.budgetStateStore = budgetStateStore; - this.costCalculator = costCalculator; this.pricingRegistry = pricingRegistry; } @Override public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) { + ChatClientRequest resolvedRequest = request; + if (budgetEvaluator != null) { Map tags = extractTagsFromRequest(request); BudgetDecision decision = budgetEvaluator.evaluate(tags); - return request.mutate() + resolvedRequest = resolvedRequest.mutate() .context(BUDGET_DECISION_CONTEXT, decision) .build(); } - return request; + + if (pricingRegistry != null) { + resolvedRequest = resolvePricing(resolvedRequest); + } + + return resolvedRequest; } @Override @@ -67,26 +76,61 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) String modelId = extractModelId(response); Map tags = extractTags(response); - ledgerManager.record(modelId, usage, tags); + Cost cost = null; + Optional plan = extractPricingPlan(response); + if (plan.isPresent()) { + cost = ledgerManager.record(plan.get(), usage, tags); + } else if (!hasPricingResolution(response)) { + cost = ledgerManager.record(modelId, usage, tags); + } // 예산 누적 처리 - if (budgetStateStore != null && costCalculator != null && pricingRegistry != null) { - Optional plan = pricingRegistry.getPlan(modelId); - if (plan.isPresent()) { - Cost cost = costCalculator.calculate(usage, plan.get()); - BudgetDecision decision = extractBudgetDecision(response); - budgetStateStore.addCost( - decision.key(), - decision.limit(), - cost - ); - } + if (budgetStateStore != null && cost != null) { + BudgetDecision decision = extractBudgetDecision(response); + budgetStateStore.addCost( + decision.key(), + decision.limit(), + cost + ); } return response; } + private ChatClientRequest resolvePricing(ChatClientRequest request) { + String modelId = extractModelId(request); + if (modelId == null) { + return request; + } + + String pricingPolicyId = extractPricingPolicyId(request); + Optional plan = pricingRegistry.getPlan(modelId, pricingPolicyId); + PricingResolution resolution = plan.isPresent() + ? PricingResolution.RESOLVED + : PricingResolution.MISSING_PLAN; + + ChatClientRequest.Builder builder = request.mutate() + .context(PRICING_POLICY_ID_CONTEXT, pricingPolicyId) + .context(PRICING_RESOLUTION_CONTEXT, resolution); + plan.ifPresent(value -> builder.context(PRICING_PLAN_CONTEXT, value)); + return builder.build(); + } + + private String extractModelId(ChatClientRequest request) { + Object value = request.context().get(MODEL_ID_CONTEXT); + if (value instanceof String modelId && !modelId.isBlank()) { + return modelId; + } + return null; + } + private String extractModelId(ChatClientResponse response) { + Map context = response.context(); + Object value = context == null ? null : context.get(MODEL_ID_CONTEXT); + if (value instanceof String modelId && !modelId.isBlank()) { + return modelId; + } + if (response.chatResponse() != null && response.chatResponse().getMetadata() != null) { String model = response.chatResponse().getMetadata().getModel(); if (model != null && !model.isBlank()) { @@ -96,6 +140,28 @@ private String extractModelId(ChatClientResponse response) { return "unknown-model"; } + private String extractPricingPolicyId(ChatClientRequest request) { + Object value = request.context().get(PRICING_POLICY_ID_CONTEXT); + if (value instanceof String pricingPolicyId && !pricingPolicyId.isBlank()) { + return pricingPolicyId; + } + return PricingPlan.DEFAULT_PRICING_POLICY_ID; + } + + private Optional extractPricingPlan(ChatClientResponse response) { + Map context = response.context(); + Object value = context == null ? null : context.get(PRICING_PLAN_CONTEXT); + if (value instanceof PricingPlan plan) { + return Optional.of(plan); + } + return Optional.empty(); + } + + private boolean hasPricingResolution(ChatClientResponse response) { + Map context = response.context(); + return context != null && context.get(PRICING_RESOLUTION_CONTEXT) instanceof PricingResolution; + } + private Map extractTags(ChatClientResponse response) { Map tags = new HashMap<>(); diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index b30eb2e..2c760d0 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -9,7 +9,6 @@ import io.tokenpilot.budget.BudgetWindow; import io.tokenpilot.core.*; import io.tokenpilot.core.domain.*; -import io.tokenpilot.springai.LedgerAdvisor; import io.tokenpilot.springai.UsageExtractor; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -77,8 +76,8 @@ void recordBudgetAfterAIResponse() { BudgetDecision budgetDecision = decision(); when(extractor.extract(any())).thenReturn(mockUsage); - when(pricingRegistry.getPlan("gpt-4o")).thenReturn(Optional.of(mockPlan)); - when(costCalculator.calculate(mockUsage, mockPlan)).thenReturn(mockCost); + when(pricingRegistry.getPlan("gpt-4o", PricingPlan.DEFAULT_PRICING_POLICY_ID)).thenReturn(Optional.of(mockPlan)); + when(ledgerManager.record(same(mockPlan), same(mockUsage), anyMap())).thenReturn(mockCost); when(budgetEvaluator.evaluate(anyMap())).thenReturn(budgetDecision); DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor(ledgerManager, extractor, @@ -86,7 +85,10 @@ void recordBudgetAfterAIResponse() { ChatClientRequest request = new ChatClientRequest( new Prompt("test"), - Map.of("tenant_id", "tenant-abc") + Map.of( + DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "gpt-4o", + "tenant_id", "tenant-abc" + ) ); ChatClientRequest resolvedRequest = advisor.before(request, mock(AdvisorChain.class)); @@ -101,6 +103,96 @@ void recordBudgetAfterAIResponse() { same(budgetDecision.limit()), same(mockCost) ); + verify(pricingRegistry, times(1)).getPlan("gpt-4o", PricingPlan.DEFAULT_PRICING_POLICY_ID); + verifyNoMoreInteractions(pricingRegistry); + } + + @Test + @DisplayName("AI 호출 전 pricing plan을 한 번 resolve하고 이후 정산에서는 registry를 다시 조회하지 않아야 한다") + void resolvePricingPlanBeforeProviderCallAndReuseItAfterResponse() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + TokenUsage usage = TokenUsage.from(100, 200); + PricingPlan plan = new PricingPlan( + "gpt-4o", + "standard", + new BigDecimal("0.01"), + new BigDecimal("0.03"), + Currency.getInstance("USD") + ); + + when(extractor.extract(any())).thenReturn(usage); + when(pricingRegistry.getPlan("gpt-4o", "standard")).thenReturn(Optional.of(plan)); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + null, + null, + mock(CostCalculator.class), + pricingRegistry + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of( + DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "gpt-4o", + DefaultLedgerAdvisor.PRICING_POLICY_ID_CONTEXT, "standard" + ) + ); + + ChatClientRequest resolvedRequest = advisor.before(request, mock(AdvisorChain.class)); + + assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT)) + .isEqualTo(PricingResolution.RESOLVED); + assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_PLAN_CONTEXT)) + .isSameAs(plan); + + ChatClientResponse response = response("gpt-4o", resolvedRequest.context()); + + advisor.after(response, mock(AdvisorChain.class)); + + verify(pricingRegistry, times(1)).getPlan("gpt-4o", "standard"); + verifyNoMoreInteractions(pricingRegistry); + verify(ledgerManager, times(1)).record(same(plan), same(usage), anyMap()); + verify(ledgerManager, never()).record(eq("gpt-4o"), same(usage), anyMap()); + } + + @Test + @DisplayName("AI 호출 전 pricing resolution 실패는 provider 호출 여부 판단 값으로 전달되어야 한다") + void exposeMissingPricingResolutionBeforeProviderCall() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + TokenUsage usage = TokenUsage.from(100, 200); + + when(extractor.extract(any())).thenReturn(usage); + when(pricingRegistry.getPlan("missing-model", PricingPlan.DEFAULT_PRICING_POLICY_ID)) + .thenReturn(Optional.empty()); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + null, + null, + mock(CostCalculator.class), + pricingRegistry + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of(DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "missing-model") + ); + + ChatClientRequest resolvedRequest = advisor.before(request, mock(AdvisorChain.class)); + + assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT)) + .isEqualTo(PricingResolution.MISSING_PLAN); + + advisor.after(response("missing-model", resolvedRequest.context()), mock(AdvisorChain.class)); + + verify(pricingRegistry, times(1)).getPlan("missing-model", PricingPlan.DEFAULT_PRICING_POLICY_ID); + verifyNoMoreInteractions(pricingRegistry); + verifyNoInteractions(ledgerManager); } @Test @@ -150,4 +242,13 @@ private static BudgetDecision decision() { Cost.of(new BigDecimal("100.00"), Currency.getInstance("USD")) ); } + + private static ChatClientResponse response(String modelId, Map context) { + ChatResponseMetadata metadata = ChatResponseMetadata.builder().model(modelId).build(); + ChatResponse chatResponse = new ChatResponse( + List.of(new Generation(new org.springframework.ai.chat.messages.AssistantMessage("test"))), + metadata + ); + return new ChatClientResponse(chatResponse, context); + } } From 3dae279e9131fbd2b8e2b9ddbe3fa0f1153b849e Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 14:36:04 +0900 Subject: [PATCH 11/32] =?UTF-8?q?feat(core):=20provider=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=A0=84=EC=97=90=20resolve=EB=90=9C=20pricing=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EC=9A=94=EC=B2=AD=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20immutable=20snapshot=EC=9C=BC=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokenPilotAutoConfigurationTest.java | 2 + .../io/tokenpilot/core/LedgerManager.java | 2 +- .../io/tokenpilot/core/PricingRegistry.java | 9 +++ .../core/domain/PricingSnapshot.java | 55 ++++++++++++++++ .../internal/InMemoryPricingRegistry.java | 12 ++++ .../core/domain/PricingSnapshotTest.java | 64 +++++++++++++++++++ .../internal/InMemoryPricingRegistryTest.java | 26 ++++++++ .../internal/DefaultLedgerAdvisor.java | 62 ++++++++---------- .../internal/DefaultLedgerAdvisorTest.java | 61 ++++++++++-------- 9 files changed, 232 insertions(+), 61 deletions(-) create mode 100644 token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingSnapshot.java create mode 100644 token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingSnapshotTest.java diff --git a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java index b3da9e0..9698ad7 100644 --- a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java +++ b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java @@ -16,6 +16,7 @@ import io.tokenpilot.core.domain.Cost; import io.tokenpilot.core.domain.PricingPlan; 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.notification.BudgetNotificationHandler; @@ -381,6 +382,7 @@ static class UserCustomPricingRegistry implements PricingRegistry { @Override public void registerPlan(PricingPlan plan) {} @Override public Optional getPlan(String modelId) { return Optional.empty(); } @Override public Optional getPlan(String modelId, String pricingPolicyId) { return Optional.empty(); } + @Override public Optional 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; } } diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/LedgerManager.java b/token-pilot-core/src/main/java/io/tokenpilot/core/LedgerManager.java index 796924e..4c1bc87 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/LedgerManager.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/LedgerManager.java @@ -1,7 +1,7 @@ package io.tokenpilot.core; import io.tokenpilot.core.domain.Cost; - import io.tokenpilot.core.domain.PricingPlan; +import io.tokenpilot.core.domain.PricingPlan; import io.tokenpilot.core.domain.TokenUsage; import java.util.Map; diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java b/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java index 8aa3a9b..3c0a5a3 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/PricingRegistry.java @@ -2,6 +2,7 @@ import io.tokenpilot.core.domain.PricingPlan; import io.tokenpilot.core.domain.PricingResolution; +import io.tokenpilot.core.domain.PricingSnapshot; import io.tokenpilot.core.domain.TokenType; import java.util.Currency; @@ -26,6 +27,14 @@ public interface PricingRegistry { */ Optional getPlan(String modelId, String pricingPolicyId); + /** + * 모델 식별자와 pricing policy id로 요청 단위 pricing snapshot을 resolve합니다. + * @param modelId 모델 식별자 + * @param pricingPolicyId pricing policy 식별자 + * @return pricing snapshot (존재하지 않을 경우 empty) + */ + Optional resolveSnapshot(String modelId, String pricingPolicyId); + /** * 모델과 토큰 타입에 대한 가격 결정 결과를 조회합니다. * @param modelId 모델 식별자 diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingSnapshot.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingSnapshot.java new file mode 100644 index 0000000..3b50116 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingSnapshot.java @@ -0,0 +1,55 @@ +package io.tokenpilot.core.domain; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Collections; +import java.util.Currency; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; + +/** + * provider 호출 전에 확정된 요청 단위 pricing snapshot. + */ +public record PricingSnapshot( + String modelId, + String pricingPolicyId, + String catalogVersion, + Instant checkedAt, + Map rates, + Currency currency +) { + public static final String DEFAULT_CATALOG_VERSION = "default"; + + public PricingSnapshot { + if (modelId == null || modelId.isBlank()) { + throw new IllegalArgumentException("modelId must not be blank"); + } + if (pricingPolicyId == null || pricingPolicyId.isBlank()) { + throw new IllegalArgumentException("pricingPolicyId must not be blank"); + } + if (catalogVersion == null || catalogVersion.isBlank()) { + throw new IllegalArgumentException("catalogVersion must not be blank"); + } + + checkedAt = Objects.requireNonNull(checkedAt, "checkedAt must not be null"); + currency = Objects.requireNonNull(currency, "currency must not be null"); + rates = Collections.unmodifiableMap(new EnumMap<>(rates)); + rates.values().forEach(rate -> { + if (rate.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("rate must not be negative"); + } + }); + } + + public static PricingSnapshot from(PricingPlan plan, String catalogVersion, Instant checkedAt) { + return new PricingSnapshot( + plan.modelId(), + plan.pricingPolicyId(), + catalogVersion, + checkedAt, + plan.rates(), + plan.currency() + ); + } +} diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/InMemoryPricingRegistry.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/InMemoryPricingRegistry.java index 78f17e3..926c7c8 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/InMemoryPricingRegistry.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/InMemoryPricingRegistry.java @@ -4,8 +4,10 @@ import io.tokenpilot.core.PricingProvider; import io.tokenpilot.core.domain.PricingPlan; import io.tokenpilot.core.domain.PricingResolution; +import io.tokenpilot.core.domain.PricingSnapshot; import io.tokenpilot.core.domain.TokenType; +import java.time.Instant; import java.util.Collection; import java.util.Currency; import java.util.List; @@ -43,6 +45,16 @@ public Optional getPlan(String modelId, String pricingPolicyId) { return Optional.ofNullable(plans.get(new PricingPlanKey(modelId, pricingPolicyId))); } + @Override + public Optional resolveSnapshot(String modelId, String pricingPolicyId) { + return getPlan(modelId, pricingPolicyId) + .map(plan -> PricingSnapshot.from( + plan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.now() + )); + } + @Override public PricingResolution resolveRate(String modelId, TokenType tokenType) { return getPlan(modelId) diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingSnapshotTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingSnapshotTest.java new file mode 100644 index 0000000..7156ebf --- /dev/null +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingSnapshotTest.java @@ -0,0 +1,64 @@ +package io.tokenpilot.core.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Currency; +import java.util.EnumMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PricingSnapshotTest { + + @Test + @DisplayName("요청 단위 pricing snapshot은 pricing 식별자와 적용 rate 정보를 보존해야 한다") + void preservePricingSnapshotValues() { + Instant checkedAt = Instant.parse("2026-07-30T00:00:00Z"); + Map rates = new EnumMap<>(TokenType.class); + rates.put(TokenType.PROMPT, new BigDecimal("0.01")); + rates.put(TokenType.COMPLETION, new BigDecimal("0.03")); + + PricingSnapshot snapshot = new PricingSnapshot( + "gpt-4o", + "standard", + "catalog-v1", + checkedAt, + rates, + Currency.getInstance("USD") + ); + + assertThat(snapshot.modelId()).isEqualTo("gpt-4o"); + assertThat(snapshot.pricingPolicyId()).isEqualTo("standard"); + assertThat(snapshot.catalogVersion()).isEqualTo("catalog-v1"); + assertThat(snapshot.checkedAt()).isEqualTo(checkedAt); + assertThat(snapshot.currency()).isEqualTo(Currency.getInstance("USD")); + assertThat(snapshot.rates()).containsEntry(TokenType.PROMPT, new BigDecimal("0.01")); + assertThat(snapshot.rates()).containsEntry(TokenType.COMPLETION, new BigDecimal("0.03")); + } + + @Test + @DisplayName("pricing snapshot rates는 생성 후 변경할 수 없어야 한다") + void ratesMustBeImmutable() { + Map rates = new EnumMap<>(TokenType.class); + rates.put(TokenType.PROMPT, new BigDecimal("0.01")); + + PricingSnapshot snapshot = new PricingSnapshot( + "gpt-4o", + "standard", + "catalog-v1", + Instant.parse("2026-07-30T00:00:00Z"), + rates, + Currency.getInstance("USD") + ); + + rates.put(TokenType.PROMPT, new BigDecimal("9.99")); + + assertThat(snapshot.rates()).containsEntry(TokenType.PROMPT, new BigDecimal("0.01")); + assertThatThrownBy(() -> snapshot.rates().put(TokenType.COMPLETION, new BigDecimal("0.03"))) + .isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java index 5288f93..9ee065b 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java @@ -2,6 +2,7 @@ import io.tokenpilot.core.domain.PricingPlan; import io.tokenpilot.core.domain.PricingResolution; +import io.tokenpilot.core.domain.PricingSnapshot; import io.tokenpilot.core.domain.TokenType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -77,6 +78,31 @@ void shouldDistinguishPlansByPricingPolicyId() { assertThat(registry.getPlan(modelId, "discounted")).contains(discountedPlan); } + @Test + @DisplayName("모델 ID와 pricing policy ID로 요청 단위 pricing snapshot을 조회할 수 있어야 한다") + void shouldGetSnapshotByModelIdAndPricingPolicyId() { + String modelId = "gpt-4o-2024-08-06"; + String pricingPolicyId = "standard"; + PricingPlan plan = new PricingPlan( + modelId, + pricingPolicyId, + Map.of(TokenType.PROMPT, new BigDecimal("0.0025")), + Currency.getInstance("USD") + ); + + registry.registerPlan(plan); + + Optional snapshot = registry.resolveSnapshot(modelId, pricingPolicyId); + + assertThat(snapshot).isPresent(); + assertThat(snapshot.get().modelId()).isEqualTo(modelId); + assertThat(snapshot.get().pricingPolicyId()).isEqualTo(pricingPolicyId); + assertThat(snapshot.get().catalogVersion()).isEqualTo(PricingSnapshot.DEFAULT_CATALOG_VERSION); + assertThat(snapshot.get().checkedAt()).isNotNull(); + assertThat(snapshot.get().currency()).isEqualTo(Currency.getInstance("USD")); + assertThat(snapshot.get().rates()).containsEntry(TokenType.PROMPT, new BigDecimal("0.0025")); + } + @Test @DisplayName("등록되지 않은 모델 조회 시 빈 Optional을 반환해야 한다") void shouldReturnEmptyWhenNotFound() { diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index 8cd4b71..b5dd4c0 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -27,13 +27,14 @@ public class DefaultLedgerAdvisor implements LedgerAdvisor { static final String BUDGET_DECISION_CONTEXT = "tokenpilot.budget.decision"; static final String MODEL_ID_CONTEXT = "tokenpilot.model.id"; static final String PRICING_POLICY_ID_CONTEXT = "tokenpilot.pricing.policy.id"; - static final String PRICING_PLAN_CONTEXT = "tokenpilot.pricing.plan"; + static final String PRICING_SNAPSHOT_CONTEXT = "tokenpilot.pricing.snapshot"; static final String PRICING_RESOLUTION_CONTEXT = "tokenpilot.pricing.resolution"; private final LedgerManager ledgerManager; private final UsageExtractor usageExtractor; private final BudgetEvaluator budgetEvaluator; private final BudgetStateStore budgetStateStore; + private final CostCalculator costCalculator; private final PricingRegistry pricingRegistry; public DefaultLedgerAdvisor(LedgerManager ledgerManager, UsageExtractor usageExtractor) { @@ -47,6 +48,7 @@ public DefaultLedgerAdvisor(LedgerManager ledgerManager, UsageExtractor usageExt this.usageExtractor = usageExtractor; this.budgetEvaluator = budgetEvaluator; this.budgetStateStore = budgetStateStore; + this.costCalculator = costCalculator; this.pricingRegistry = pricingRegistry; } @@ -76,22 +78,20 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) String modelId = extractModelId(response); Map tags = extractTags(response); - Cost cost = null; - Optional plan = extractPricingPlan(response); - if (plan.isPresent()) { - cost = ledgerManager.record(plan.get(), usage, tags); - } else if (!hasPricingResolution(response)) { - cost = ledgerManager.record(modelId, usage, tags); - } + ledgerManager.record(modelId, usage, tags); // 예산 누적 처리 - if (budgetStateStore != null && cost != null) { - BudgetDecision decision = extractBudgetDecision(response); - budgetStateStore.addCost( - decision.key(), - decision.limit(), - cost - ); + if (budgetStateStore != null && costCalculator != null && pricingRegistry != null) { + Optional plan = pricingRegistry.getPlan(modelId); + if (plan.isPresent()) { + Cost cost = costCalculator.calculate(usage, plan.get()); + BudgetDecision decision = extractBudgetDecision(response); + budgetStateStore.addCost( + decision.key(), + decision.limit(), + cost + ); + } } return response; @@ -104,15 +104,15 @@ private ChatClientRequest resolvePricing(ChatClientRequest request) { } String pricingPolicyId = extractPricingPolicyId(request); - Optional plan = pricingRegistry.getPlan(modelId, pricingPolicyId); - PricingResolution resolution = plan.isPresent() + Optional snapshot = pricingRegistry.resolveSnapshot(modelId, pricingPolicyId); + PricingResolution resolution = snapshot.isPresent() ? PricingResolution.RESOLVED : PricingResolution.MISSING_PLAN; ChatClientRequest.Builder builder = request.mutate() .context(PRICING_POLICY_ID_CONTEXT, pricingPolicyId) .context(PRICING_RESOLUTION_CONTEXT, resolution); - plan.ifPresent(value -> builder.context(PRICING_PLAN_CONTEXT, value)); + snapshot.ifPresent(value -> builder.context(PRICING_SNAPSHOT_CONTEXT, value)); return builder.build(); } @@ -126,7 +126,11 @@ private String extractModelId(ChatClientRequest request) { private String extractModelId(ChatClientResponse response) { Map context = response.context(); - Object value = context == null ? null : context.get(MODEL_ID_CONTEXT); + Object value = null; + if (context != null) { + value = context.get(MODEL_ID_CONTEXT); + } + if (value instanceof String modelId && !modelId.isBlank()) { return modelId; } @@ -148,20 +152,6 @@ private String extractPricingPolicyId(ChatClientRequest request) { return PricingPlan.DEFAULT_PRICING_POLICY_ID; } - private Optional extractPricingPlan(ChatClientResponse response) { - Map context = response.context(); - Object value = context == null ? null : context.get(PRICING_PLAN_CONTEXT); - if (value instanceof PricingPlan plan) { - return Optional.of(plan); - } - return Optional.empty(); - } - - private boolean hasPricingResolution(ChatClientResponse response) { - Map context = response.context(); - return context != null && context.get(PRICING_RESOLUTION_CONTEXT) instanceof PricingResolution; - } - private Map extractTags(ChatClientResponse response) { Map tags = new HashMap<>(); @@ -179,7 +169,11 @@ private Map extractTags(ChatClientResponse response) { private BudgetDecision extractBudgetDecision(ChatClientResponse response) { Map context = response.context(); - Object value = context == null ? null : context.get(BUDGET_DECISION_CONTEXT); + Object value = null; + if (context != null) { + value = context.get(BUDGET_DECISION_CONTEXT); + } + if (value instanceof BudgetDecision decision) { return decision; } diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index 2c760d0..cc5eee0 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -21,6 +21,7 @@ import org.springframework.ai.chat.prompt.Prompt; import java.math.BigDecimal; +import java.time.Instant; import java.util.Currency; import java.util.List; import java.util.Map; @@ -76,8 +77,14 @@ void recordBudgetAfterAIResponse() { BudgetDecision budgetDecision = decision(); when(extractor.extract(any())).thenReturn(mockUsage); - when(pricingRegistry.getPlan("gpt-4o", PricingPlan.DEFAULT_PRICING_POLICY_ID)).thenReturn(Optional.of(mockPlan)); - when(ledgerManager.record(same(mockPlan), same(mockUsage), anyMap())).thenReturn(mockCost); + when(pricingRegistry.resolveSnapshot("gpt-4o", PricingPlan.DEFAULT_PRICING_POLICY_ID)) + .thenReturn(Optional.of(PricingSnapshot.from( + mockPlan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ))); + when(pricingRegistry.getPlan("gpt-4o")).thenReturn(Optional.of(mockPlan)); + when(costCalculator.calculate(mockUsage, mockPlan)).thenReturn(mockCost); when(budgetEvaluator.evaluate(anyMap())).thenReturn(budgetDecision); DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor(ledgerManager, extractor, @@ -103,17 +110,16 @@ void recordBudgetAfterAIResponse() { same(budgetDecision.limit()), same(mockCost) ); - verify(pricingRegistry, times(1)).getPlan("gpt-4o", PricingPlan.DEFAULT_PRICING_POLICY_ID); + verify(pricingRegistry, times(1)).resolveSnapshot("gpt-4o", PricingPlan.DEFAULT_PRICING_POLICY_ID); + verify(pricingRegistry, times(1)).getPlan("gpt-4o"); verifyNoMoreInteractions(pricingRegistry); } @Test - @DisplayName("AI 호출 전 pricing plan을 한 번 resolve하고 이후 정산에서는 registry를 다시 조회하지 않아야 한다") - void resolvePricingPlanBeforeProviderCallAndReuseItAfterResponse() { + @DisplayName("AI 호출 전 pricing snapshot을 만들어 요청 context에 보존해야 한다") + void createPricingSnapshotBeforeProviderCall() { LedgerManager ledgerManager = mock(LedgerManager.class); - UsageExtractor extractor = mock(UsageExtractor.class); PricingRegistry pricingRegistry = mock(PricingRegistry.class); - TokenUsage usage = TokenUsage.from(100, 200); PricingPlan plan = new PricingPlan( "gpt-4o", "standard", @@ -121,13 +127,17 @@ void resolvePricingPlanBeforeProviderCallAndReuseItAfterResponse() { new BigDecimal("0.03"), Currency.getInstance("USD") ); + PricingSnapshot snapshot = PricingSnapshot.from( + plan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); - when(extractor.extract(any())).thenReturn(usage); - when(pricingRegistry.getPlan("gpt-4o", "standard")).thenReturn(Optional.of(plan)); + when(pricingRegistry.resolveSnapshot("gpt-4o", "standard")).thenReturn(Optional.of(snapshot)); DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( ledgerManager, - extractor, + mock(UsageExtractor.class), null, null, mock(CostCalculator.class), @@ -145,17 +155,19 @@ void resolvePricingPlanBeforeProviderCallAndReuseItAfterResponse() { assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT)) .isEqualTo(PricingResolution.RESOLVED); - assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_PLAN_CONTEXT)) - .isSameAs(plan); - - ChatClientResponse response = response("gpt-4o", resolvedRequest.context()); - - advisor.after(response, mock(AdvisorChain.class)); - - verify(pricingRegistry, times(1)).getPlan("gpt-4o", "standard"); + assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_SNAPSHOT_CONTEXT)) + .isInstanceOfSatisfying(PricingSnapshot.class, resolvedSnapshot -> { + assertThat(resolvedSnapshot.modelId()).isEqualTo("gpt-4o"); + assertThat(resolvedSnapshot.pricingPolicyId()).isEqualTo("standard"); + assertThat(resolvedSnapshot.catalogVersion()).isEqualTo(PricingSnapshot.DEFAULT_CATALOG_VERSION); + assertThat(resolvedSnapshot.checkedAt()).isEqualTo(snapshot.checkedAt()); + assertThat(resolvedSnapshot.currency()).isEqualTo(Currency.getInstance("USD")); + assertThat(resolvedSnapshot.rates()).containsAllEntriesOf(plan.rates()); + }); + + verify(pricingRegistry, times(1)).resolveSnapshot("gpt-4o", "standard"); verifyNoMoreInteractions(pricingRegistry); - verify(ledgerManager, times(1)).record(same(plan), same(usage), anyMap()); - verify(ledgerManager, never()).record(eq("gpt-4o"), same(usage), anyMap()); + verifyNoInteractions(ledgerManager); } @Test @@ -164,10 +176,8 @@ void exposeMissingPricingResolutionBeforeProviderCall() { LedgerManager ledgerManager = mock(LedgerManager.class); UsageExtractor extractor = mock(UsageExtractor.class); PricingRegistry pricingRegistry = mock(PricingRegistry.class); - TokenUsage usage = TokenUsage.from(100, 200); - when(extractor.extract(any())).thenReturn(usage); - when(pricingRegistry.getPlan("missing-model", PricingPlan.DEFAULT_PRICING_POLICY_ID)) + when(pricingRegistry.resolveSnapshot("missing-model", PricingPlan.DEFAULT_PRICING_POLICY_ID)) .thenReturn(Optional.empty()); DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( @@ -187,10 +197,9 @@ void exposeMissingPricingResolutionBeforeProviderCall() { assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT)) .isEqualTo(PricingResolution.MISSING_PLAN); + assertThat(resolvedRequest.context()).doesNotContainKey(DefaultLedgerAdvisor.PRICING_SNAPSHOT_CONTEXT); - advisor.after(response("missing-model", resolvedRequest.context()), mock(AdvisorChain.class)); - - verify(pricingRegistry, times(1)).getPlan("missing-model", PricingPlan.DEFAULT_PRICING_POLICY_ID); + verify(pricingRegistry, times(1)).resolveSnapshot("missing-model", PricingPlan.DEFAULT_PRICING_POLICY_ID); verifyNoMoreInteractions(pricingRegistry); verifyNoInteractions(ledgerManager); } From bd0a415074a739d0098c8b8e7a0fa75d88637690 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 14:53:28 +0900 Subject: [PATCH 12/32] =?UTF-8?q?feat(core):=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EC=A4=91=20registry=EA=B0=80=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EB=90=98=EC=96=B4=EB=8F=84=20actual=20reconciliation?= =?UTF-8?q?=EC=9D=80=20=EC=98=88=EC=95=BD=20=EC=8B=9C=EC=A0=90=20pricing?= =?UTF-8?q?=20snapshot=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/tokenpilot/core/LedgerManager.java | 10 +++ .../core/internal/DefaultLedgerManager.java | 13 ++++ .../internal/DefaultLedgerManagerTest.java | 23 +++++++ .../internal/InMemoryPricingRegistryTest.java | 26 ++++++++ .../internal/DefaultLedgerAdvisor.java | 55 ++++++++++++++-- .../internal/DefaultLedgerAdvisorTest.java | 65 ++++++++++++++++--- 6 files changed, 177 insertions(+), 15 deletions(-) diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/LedgerManager.java b/token-pilot-core/src/main/java/io/tokenpilot/core/LedgerManager.java index 4c1bc87..e4a96dd 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/LedgerManager.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/LedgerManager.java @@ -2,6 +2,7 @@ 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; @@ -27,4 +28,13 @@ public interface LedgerManager { * @return 산출된 비용 */ Cost record(PricingPlan plan, TokenUsage usage, Map tags); + + /** + * 요청 단위 pricing snapshot으로 호출 정보를 기록하고 최종 비용을 계산합니다. + * @param snapshot provider 호출 전에 보존된 pricing snapshot + * @param usage 토큰 사용량 + * @param tags 추가 메타데이터 (tenant_id, user_id 등) + * @return 산출된 비용 + */ + Cost record(PricingSnapshot snapshot, TokenUsage usage, Map tags); } diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java index 3d4a589..ccefc71 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java @@ -61,6 +61,19 @@ public Cost record(PricingPlan plan, TokenUsage usage, Map tags) return cost; } + @Override + public Cost record(PricingSnapshot snapshot, TokenUsage usage, Map tags) { + PricingPlan plan = new PricingPlan( + snapshot.modelId(), + snapshot.pricingPolicyId(), + snapshot.rates(), + snapshot.currency() + ); + Cost cost = costCalculator.calculate(usage, plan); + publish(snapshot.modelId(), usage, cost, tags); + return cost; + } + private void publish(String modelId, TokenUsage usage, Cost cost, Map tags) { // 이벤트 발행 (리스너들에게 전파) if (!listeners.isEmpty()) { diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultLedgerManagerTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultLedgerManagerTest.java index 7ae30ee..21ddc44 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultLedgerManagerTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultLedgerManagerTest.java @@ -8,6 +8,7 @@ import org.mockito.Mockito; import java.math.BigDecimal; +import java.time.Instant; import java.util.Currency; import java.util.List; import java.util.Map; @@ -82,4 +83,26 @@ void shouldRecordWithResolvedPlanWithoutRegistryLookup() { event.cost().equals(expectedCost) )); } + + @Test + @DisplayName("pricing snapshot으로 기록하면 registry 변경 후에도 snapshot rate를 사용해야 한다") + void shouldRecordWithSnapshotRatesAfterRegistryChanges() { + PricingPlan originalPlan = new PricingPlan("gpt-4o", new BigDecimal("5.0"), new BigDecimal("15.0")); + PricingSnapshot snapshot = PricingSnapshot.from( + originalPlan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + registry.registerPlan(new PricingPlan("gpt-4o", new BigDecimal("50.0"), new BigDecimal("150.0"))); + TokenUsage usage = TokenUsage.from(1000, 1000); + + Cost cost = manager.record(snapshot, usage, Map.of()); + + assertThat(cost.value()).isEqualByComparingTo("20.000000"); + verify(listener).onRecord(argThat(event -> + event.modelId().equals("gpt-4o") && + event.usage().equals(usage) && + event.cost().equals(cost) + )); + } } diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java index 9ee065b..9114fda 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java @@ -103,6 +103,32 @@ void shouldGetSnapshotByModelIdAndPricingPolicyId() { assertThat(snapshot.get().rates()).containsEntry(TokenType.PROMPT, new BigDecimal("0.0025")); } + @Test + @DisplayName("registry 변경 후 새 요청은 변경된 pricing plan으로 snapshot을 resolve해야 한다") + void shouldResolveNewSnapshotAfterRegistryChanges() { + String modelId = "gpt-4o-2024-08-06"; + String pricingPolicyId = "standard"; + registry.registerPlan(new PricingPlan( + modelId, + pricingPolicyId, + Map.of(TokenType.PROMPT, new BigDecimal("0.0025")), + Currency.getInstance("USD") + )); + PricingSnapshot firstSnapshot = registry.resolveSnapshot(modelId, pricingPolicyId).orElseThrow(); + + registry.registerPlan(new PricingPlan( + modelId, + pricingPolicyId, + Map.of(TokenType.PROMPT, new BigDecimal("0.0050")), + Currency.getInstance("USD") + )); + + PricingSnapshot nextSnapshot = registry.resolveSnapshot(modelId, pricingPolicyId).orElseThrow(); + + assertThat(firstSnapshot.rates()).containsEntry(TokenType.PROMPT, new BigDecimal("0.0025")); + assertThat(nextSnapshot.rates()).containsEntry(TokenType.PROMPT, new BigDecimal("0.0050")); + } + @Test @DisplayName("등록되지 않은 모델 조회 시 빈 Optional을 반환해야 한다") void shouldReturnEmptyWhenNotFound() { diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index b5dd4c0..a47b5ee 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -78,13 +78,11 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) String modelId = extractModelId(response); Map tags = extractTags(response); - ledgerManager.record(modelId, usage, tags); - - // 예산 누적 처리 - if (budgetStateStore != null && costCalculator != null && pricingRegistry != null) { - Optional plan = pricingRegistry.getPlan(modelId); - if (plan.isPresent()) { - Cost cost = costCalculator.calculate(usage, plan.get()); + Optional snapshot = extractPricingSnapshot(response); + boolean hasPricingResolution = hasPricingResolution(response); + if (snapshot.isPresent()) { + Cost cost = ledgerManager.record(snapshot.get(), usage, tags); + if (budgetStateStore != null) { BudgetDecision decision = extractBudgetDecision(response); budgetStateStore.addCost( decision.key(), @@ -92,11 +90,33 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) cost ); } + } else if (!hasPricingResolution) { + ledgerManager.record(modelId, usage, tags); + recordLegacyBudgetCost(modelId, usage, response); } return response; } + private void recordLegacyBudgetCost(String modelId, TokenUsage usage, ChatClientResponse response) { + if (budgetStateStore == null || costCalculator == null || pricingRegistry == null) { + return; + } + + Optional plan = pricingRegistry.getPlan(modelId); + if (plan.isEmpty()) { + return; + } + + Cost cost = costCalculator.calculate(usage, plan.get()); + BudgetDecision decision = extractBudgetDecision(response); + budgetStateStore.addCost( + decision.key(), + decision.limit(), + cost + ); + } + private ChatClientRequest resolvePricing(ChatClientRequest request) { String modelId = extractModelId(request); if (modelId == null) { @@ -152,6 +172,27 @@ private String extractPricingPolicyId(ChatClientRequest request) { return PricingPlan.DEFAULT_PRICING_POLICY_ID; } + private Optional extractPricingSnapshot(ChatClientResponse response) { + Map context = response.context(); + Object value = null; + if (context != null) { + value = context.get(PRICING_SNAPSHOT_CONTEXT); + } + + if (value instanceof PricingSnapshot snapshot) { + return Optional.of(snapshot); + } + return Optional.empty(); + } + + private boolean hasPricingResolution(ChatClientResponse response) { + Map context = response.context(); + if (context == null) { + return false; + } + return context.get(PRICING_RESOLUTION_CONTEXT) instanceof PricingResolution; + } + private Map extractTags(ChatClientResponse response) { Map tags = new HashMap<>(); diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index cc5eee0..f1762ed 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -73,18 +73,18 @@ void recordBudgetAfterAIResponse() { TokenUsage mockUsage = TokenUsage.from(100, 200); PricingPlan mockPlan = new PricingPlan("gpt-4o", new BigDecimal("0.01"), new BigDecimal("0.03"), Currency.getInstance("USD")); + PricingSnapshot snapshot = PricingSnapshot.from( + mockPlan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); Cost mockCost = new Cost(new BigDecimal("0.5"), Currency.getInstance("USD")); BudgetDecision budgetDecision = decision(); when(extractor.extract(any())).thenReturn(mockUsage); when(pricingRegistry.resolveSnapshot("gpt-4o", PricingPlan.DEFAULT_PRICING_POLICY_ID)) - .thenReturn(Optional.of(PricingSnapshot.from( - mockPlan, - PricingSnapshot.DEFAULT_CATALOG_VERSION, - Instant.parse("2026-07-30T00:00:00Z") - ))); - when(pricingRegistry.getPlan("gpt-4o")).thenReturn(Optional.of(mockPlan)); - when(costCalculator.calculate(mockUsage, mockPlan)).thenReturn(mockCost); + .thenReturn(Optional.of(snapshot)); + when(ledgerManager.record(same(snapshot), same(mockUsage), anyMap())).thenReturn(mockCost); when(budgetEvaluator.evaluate(anyMap())).thenReturn(budgetDecision); DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor(ledgerManager, extractor, @@ -111,7 +111,6 @@ void recordBudgetAfterAIResponse() { same(mockCost) ); verify(pricingRegistry, times(1)).resolveSnapshot("gpt-4o", PricingPlan.DEFAULT_PRICING_POLICY_ID); - verify(pricingRegistry, times(1)).getPlan("gpt-4o"); verifyNoMoreInteractions(pricingRegistry); } @@ -170,6 +169,54 @@ void createPricingSnapshotBeforeProviderCall() { verifyNoInteractions(ledgerManager); } + @Test + @DisplayName("AI 응답 후 actual reconciliation은 registry를 다시 조회하지 않고 snapshot으로 기록해야 한다") + void reconcileActualWithPricingSnapshot() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + TokenUsage usage = TokenUsage.from(100, 200); + PricingPlan plan = new PricingPlan( + "gpt-4o", + "standard", + new BigDecimal("0.01"), + new BigDecimal("0.03"), + Currency.getInstance("USD") + ); + PricingSnapshot snapshot = PricingSnapshot.from( + plan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + + when(extractor.extract(any())).thenReturn(usage); + when(pricingRegistry.resolveSnapshot("gpt-4o", "standard")).thenReturn(Optional.of(snapshot)); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + null, + null, + mock(CostCalculator.class), + pricingRegistry + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of( + DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "gpt-4o", + DefaultLedgerAdvisor.PRICING_POLICY_ID_CONTEXT, "standard" + ) + ); + ChatClientRequest resolvedRequest = advisor.before(request, mock(AdvisorChain.class)); + + advisor.after(response("gpt-4o", resolvedRequest.context()), mock(AdvisorChain.class)); + + verify(pricingRegistry, times(1)).resolveSnapshot("gpt-4o", "standard"); + verifyNoMoreInteractions(pricingRegistry); + verify(ledgerManager, times(1)).record(same(snapshot), same(usage), anyMap()); + verify(ledgerManager, never()).record(eq("gpt-4o"), same(usage), anyMap()); + } + @Test @DisplayName("AI 호출 전 pricing resolution 실패는 provider 호출 여부 판단 값으로 전달되어야 한다") void exposeMissingPricingResolutionBeforeProviderCall() { @@ -199,6 +246,8 @@ void exposeMissingPricingResolutionBeforeProviderCall() { .isEqualTo(PricingResolution.MISSING_PLAN); assertThat(resolvedRequest.context()).doesNotContainKey(DefaultLedgerAdvisor.PRICING_SNAPSHOT_CONTEXT); + advisor.after(response("missing-model", resolvedRequest.context()), mock(AdvisorChain.class)); + verify(pricingRegistry, times(1)).resolveSnapshot("missing-model", PricingPlan.DEFAULT_PRICING_POLICY_ID); verifyNoMoreInteractions(pricingRegistry); verifyNoInteractions(ledgerManager); From d9ebd038be99f2d64185aac464f5a642f9779b58 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 15:18:50 +0900 Subject: [PATCH 13/32] =?UTF-8?q?feat(core):=20provider=20response=20model?= =?UTF-8?q?=EC=9D=B4=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C=EC=A0=90=20snapshot?= =?UTF-8?q?=EC=9D=98=20model=20id=EC=99=80=20=EB=8B=A4=EB=A5=B4=EB=A9=B4?= =?UTF-8?q?=20=EA=B8=B0=EC=A1=B4=20snapshot=EC=9D=84=20=EC=95=94=EB=AC=B5?= =?UTF-8?q?=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=A0=81=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20reconciliation=20required=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/PricingReconciliationResult.java | 9 +++ .../core/domain/PricingResolutionTest.java | 7 ++ .../internal/DefaultLedgerAdvisor.java | 67 +++++++++++++------ .../internal/DefaultLedgerAdvisorTest.java | 60 ++++++++++++++++- 4 files changed, 120 insertions(+), 23 deletions(-) create mode 100644 token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingReconciliationResult.java diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingReconciliationResult.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingReconciliationResult.java new file mode 100644 index 0000000..5d74282 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingReconciliationResult.java @@ -0,0 +1,9 @@ +package io.tokenpilot.core.domain; + +/** + * Actual reconciliation 결과. + */ +public enum PricingReconciliationResult { + RECONCILED, + RECONCILIATION_REQUIRED +} diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java index a548829..7ab0c86 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java @@ -64,4 +64,11 @@ void resolutionItselfIsLowCardinalityReason() { assertThat(PricingResolution.MISSING_RATE.name()).isEqualTo("MISSING_RATE"); assertThat(PricingResolution.CURRENCY_MISMATCH.name()).isEqualTo("CURRENCY_MISMATCH"); } + + @Test + @DisplayName("PricingResolution은 RECONCILIATION_REQUIRED 상태값을 갖지 않는다") + void reconciliationRequiredIsNotPricingResolution() { + assertThat(PricingResolution.values()) + .noneMatch(value -> value.name().equals("RECONCILIATION_REQUIRED")); + } } diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index a47b5ee..18dd748 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -29,6 +29,7 @@ public class DefaultLedgerAdvisor implements LedgerAdvisor { static final String PRICING_POLICY_ID_CONTEXT = "tokenpilot.pricing.policy.id"; static final String PRICING_SNAPSHOT_CONTEXT = "tokenpilot.pricing.snapshot"; static final String PRICING_RESOLUTION_CONTEXT = "tokenpilot.pricing.resolution"; + static final String PRICING_RECONCILIATION_RESULT_CONTEXT = "tokenpilot.pricing.reconciliation.result"; private final LedgerManager ledgerManager; private final UsageExtractor usageExtractor; @@ -57,7 +58,7 @@ public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) { ChatClientRequest resolvedRequest = request; if (budgetEvaluator != null) { - Map tags = extractTagsFromRequest(request); + Map tags = extractTags(request.context()); BudgetDecision decision = budgetEvaluator.evaluate(tags); resolvedRequest = resolvedRequest.mutate() .context(BUDGET_DECISION_CONTEXT, decision) @@ -76,20 +77,30 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) TokenUsage usage = usageExtractor.extract(response); String modelId = extractModelId(response); + String responseModelId = extractResponseModelId(response); Map tags = extractTags(response); Optional snapshot = extractPricingSnapshot(response); boolean hasPricingResolution = hasPricingResolution(response); if (snapshot.isPresent()) { + if (!snapshot.get().modelId().equals(responseModelId)) { + return withReconciliationResult(response, PricingReconciliationResult.RECONCILIATION_REQUIRED); + } + Cost cost = ledgerManager.record(snapshot.get(), usage, tags); + ChatClientResponse reconciledResponse = withReconciliationResult( + response, + PricingReconciliationResult.RECONCILED + ); if (budgetStateStore != null) { - BudgetDecision decision = extractBudgetDecision(response); + BudgetDecision decision = extractBudgetDecision(reconciledResponse); budgetStateStore.addCost( decision.key(), decision.limit(), cost ); } + return reconciledResponse; } else if (!hasPricingResolution) { ledgerManager.record(modelId, usage, tags); recordLegacyBudgetCost(modelId, usage, response); @@ -98,6 +109,18 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) return response; } + private ChatClientResponse withReconciliationResult( + ChatClientResponse response, + PricingReconciliationResult result + ) { + Map context = new HashMap<>(); + if (response.context() != null) { + context.putAll(response.context()); + } + context.put(PRICING_RECONCILIATION_RESULT_CONTEXT, result); + return new ChatClientResponse(response.chatResponse(), context); + } + private void recordLegacyBudgetCost(String modelId, TokenUsage usage, ChatClientResponse response) { if (budgetStateStore == null || costCalculator == null || pricingRegistry == null) { return; @@ -164,6 +187,16 @@ private String extractModelId(ChatClientResponse response) { return "unknown-model"; } + private String extractResponseModelId(ChatClientResponse response) { + if (response.chatResponse() != null && response.chatResponse().getMetadata() != null) { + String model = response.chatResponse().getMetadata().getModel(); + if (model != null && !model.isBlank()) { + return model; + } + } + return extractModelId(response); + } + private String extractPricingPolicyId(ChatClientRequest request) { Object value = request.context().get(PRICING_POLICY_ID_CONTEXT); if (value instanceof String pricingPolicyId && !pricingPolicyId.isBlank()) { @@ -194,18 +227,7 @@ private boolean hasPricingResolution(ChatClientResponse response) { } private Map extractTags(ChatClientResponse response) { - Map tags = new HashMap<>(); - - Map context = response.context(); - if (context != null) { - context.forEach((k, v) -> { - if (v instanceof String s) { - tags.put(k, s); - } - }); - } - - return tags; + return extractTags(response.context()); } private BudgetDecision extractBudgetDecision(ChatClientResponse response) { @@ -221,16 +243,17 @@ private BudgetDecision extractBudgetDecision(ChatClientResponse response) { throw new IllegalStateException("Resolved budget decision is missing from response context"); } - private Map extractTagsFromRequest(ChatClientRequest request) { + private Map extractTags(Map context) { Map tags = new HashMap<>(); - Map context = request.context(); - if (context != null) { - context.forEach((k, v) -> { - if (v instanceof String s) { - tags.put(k, s); - } - }); + if (context == null) { + return tags; } + + context.forEach((k, v) -> { + if (v instanceof String s) { + tags.put(k, s); + } + }); return tags; } } diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index f1762ed..084c75e 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -209,7 +209,13 @@ void reconcileActualWithPricingSnapshot() { ); ChatClientRequest resolvedRequest = advisor.before(request, mock(AdvisorChain.class)); - advisor.after(response("gpt-4o", resolvedRequest.context()), mock(AdvisorChain.class)); + ChatClientResponse reconciledResponse = advisor.after( + response("gpt-4o", resolvedRequest.context()), + mock(AdvisorChain.class) + ); + + assertThat(reconciledResponse.context().get(DefaultLedgerAdvisor.PRICING_RECONCILIATION_RESULT_CONTEXT)) + .isEqualTo(PricingReconciliationResult.RECONCILED); verify(pricingRegistry, times(1)).resolveSnapshot("gpt-4o", "standard"); verifyNoMoreInteractions(pricingRegistry); @@ -217,6 +223,58 @@ void reconcileActualWithPricingSnapshot() { verify(ledgerManager, never()).record(eq("gpt-4o"), same(usage), anyMap()); } + @Test + @DisplayName("response model이 snapshot model과 다르면 기존 snapshot을 자동 적용하지 않아야 한다") + void requireReconciliationWhenResponseModelDiffersFromSnapshotModel() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + TokenUsage usage = TokenUsage.from(100, 200); + PricingPlan plan = new PricingPlan( + "gpt-4o-mini", + "standard", + new BigDecimal("0.01"), + new BigDecimal("0.03"), + Currency.getInstance("USD") + ); + PricingSnapshot snapshot = PricingSnapshot.from( + plan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + + when(extractor.extract(any())).thenReturn(usage); + when(pricingRegistry.resolveSnapshot("gpt-4o-mini", "standard")).thenReturn(Optional.of(snapshot)); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + null, + null, + mock(CostCalculator.class), + pricingRegistry + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of( + DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "gpt-4o-mini", + DefaultLedgerAdvisor.PRICING_POLICY_ID_CONTEXT, "standard" + ) + ); + ChatClientRequest resolvedRequest = advisor.before(request, mock(AdvisorChain.class)); + + ChatClientResponse result = advisor.after( + response("gpt-4o", resolvedRequest.context()), + mock(AdvisorChain.class) + ); + + assertThat(result.context().get(DefaultLedgerAdvisor.PRICING_RECONCILIATION_RESULT_CONTEXT)) + .isEqualTo(PricingReconciliationResult.RECONCILIATION_REQUIRED); + verify(pricingRegistry, times(1)).resolveSnapshot("gpt-4o-mini", "standard"); + verifyNoMoreInteractions(pricingRegistry); + verifyNoInteractions(ledgerManager); + } + @Test @DisplayName("AI 호출 전 pricing resolution 실패는 provider 호출 여부 판단 값으로 전달되어야 한다") void exposeMissingPricingResolutionBeforeProviderCall() { From 2bf8ee6e4907a33c61b87d54068d7b0cb592730c Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 15:20:35 +0900 Subject: [PATCH 14/32] =?UTF-8?q?refactor(core):=20core=20ledger=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20=EC=A4=91=EB=B3=B5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tokenpilot/core/internal/DefaultLedgerManager.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java index ccefc71..e9e66f4 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java @@ -56,9 +56,7 @@ public Cost record(String modelId, TokenUsage usage, Map tags) { @Override public Cost record(PricingPlan plan, TokenUsage usage, Map tags) { - Cost cost = costCalculator.calculate(usage, plan); - publish(plan.modelId(), usage, cost, tags); - return cost; + return recordResolvedPlan(plan, usage, tags); } @Override @@ -69,8 +67,12 @@ public Cost record(PricingSnapshot snapshot, TokenUsage usage, Map tags) { Cost cost = costCalculator.calculate(usage, plan); - publish(snapshot.modelId(), usage, cost, tags); + publish(plan.modelId(), usage, cost, tags); return cost; } From 4ef88b0da2c8b1230bc4f7aa2508246b4096d682 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 15:50:22 +0900 Subject: [PATCH 15/32] =?UTF-8?q?feat(core):=20missing=20pricing=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=EC=9D=98=20fail=20closed/open=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/PricingReconciliationResult.java | 3 +- .../exception/MissingPricingException.java | 16 ++++++ .../core/domain/PricingResolutionTest.java | 7 +++ .../MissingPricingExceptionTest.java | 19 +++++++ .../internal/DefaultLedgerAdvisor.java | 31 ++++++++++ .../internal/DefaultLedgerAdvisorTest.java | 57 ++++++++++++++++++- 6 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 token-pilot-core/src/main/java/io/tokenpilot/core/exception/MissingPricingException.java create mode 100644 token-pilot-core/src/test/java/io/tokenpilot/core/exception/MissingPricingExceptionTest.java diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingReconciliationResult.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingReconciliationResult.java index 5d74282..8964bd1 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingReconciliationResult.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingReconciliationResult.java @@ -5,5 +5,6 @@ */ public enum PricingReconciliationResult { RECONCILED, - RECONCILIATION_REQUIRED + RECONCILIATION_REQUIRED, + UNPRICED } diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/exception/MissingPricingException.java b/token-pilot-core/src/main/java/io/tokenpilot/core/exception/MissingPricingException.java new file mode 100644 index 0000000..857f7b4 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/exception/MissingPricingException.java @@ -0,0 +1,16 @@ +package io.tokenpilot.core.exception; + +import io.tokenpilot.core.domain.PricingResolution; + +public class MissingPricingException extends RuntimeException { + private final PricingResolution resolution; + + public MissingPricingException(PricingResolution resolution) { + super(resolution.name()); + this.resolution = resolution; + } + + public PricingResolution getResolution() { + return resolution; + } +} diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java index 7ab0c86..300a8cf 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java @@ -71,4 +71,11 @@ void reconciliationRequiredIsNotPricingResolution() { assertThat(PricingResolution.values()) .noneMatch(value -> value.name().equals("RECONCILIATION_REQUIRED")); } + + @Test + @DisplayName("PricingResolution은 UNPRICED 상태값을 갖지 않는다") + void unpricedIsNotPricingResolution() { + assertThat(PricingResolution.values()) + .noneMatch(value -> value.name().equals("UNPRICED")); + } } diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/exception/MissingPricingExceptionTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/exception/MissingPricingExceptionTest.java new file mode 100644 index 0000000..e6a867f --- /dev/null +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/exception/MissingPricingExceptionTest.java @@ -0,0 +1,19 @@ +package io.tokenpilot.core.exception; + +import io.tokenpilot.core.domain.PricingResolution; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MissingPricingExceptionTest { + + @Test + @DisplayName("MissingPricingException은 PricingResolution을 구조화된 값으로 보존한다") + void preservesPricingResolution() { + MissingPricingException exception = new MissingPricingException(PricingResolution.MISSING_PLAN); + + assertThat(exception).hasMessage("MISSING_PLAN"); + assertThat(exception.getResolution()).isEqualTo(PricingResolution.MISSING_PLAN); + } +} diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index 18dd748..353d42c 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -5,6 +5,7 @@ import io.tokenpilot.budget.BudgetStateStore; import io.tokenpilot.core.*; import io.tokenpilot.core.domain.*; +import io.tokenpilot.core.exception.MissingPricingException; import io.tokenpilot.springai.LedgerAdvisor; import io.tokenpilot.springai.UsageExtractor; import org.springframework.ai.chat.client.ChatClientRequest; @@ -37,6 +38,7 @@ public class DefaultLedgerAdvisor implements LedgerAdvisor { private final BudgetStateStore budgetStateStore; private final CostCalculator costCalculator; private final PricingRegistry pricingRegistry; + private final MissingPricingPolicy missingPricingPolicy; public DefaultLedgerAdvisor(LedgerManager ledgerManager, UsageExtractor usageExtractor) { this(ledgerManager, usageExtractor, null, null, null, null); @@ -45,12 +47,28 @@ public DefaultLedgerAdvisor(LedgerManager ledgerManager, UsageExtractor usageExt public DefaultLedgerAdvisor(LedgerManager ledgerManager, UsageExtractor usageExtractor, BudgetEvaluator budgetEvaluator, BudgetStateStore budgetStateStore, CostCalculator costCalculator, PricingRegistry pricingRegistry) { + this( + ledgerManager, + usageExtractor, + budgetEvaluator, + budgetStateStore, + costCalculator, + pricingRegistry, + MissingPricingPolicy.FAIL_OPEN + ); + } + + public DefaultLedgerAdvisor(LedgerManager ledgerManager, UsageExtractor usageExtractor, + BudgetEvaluator budgetEvaluator, BudgetStateStore budgetStateStore, + CostCalculator costCalculator, PricingRegistry pricingRegistry, + MissingPricingPolicy missingPricingPolicy) { this.ledgerManager = ledgerManager; this.usageExtractor = usageExtractor; this.budgetEvaluator = budgetEvaluator; this.budgetStateStore = budgetStateStore; this.costCalculator = costCalculator; this.pricingRegistry = pricingRegistry; + this.missingPricingPolicy = missingPricingPolicy; } @Override @@ -104,6 +122,8 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) } else if (!hasPricingResolution) { ledgerManager.record(modelId, usage, tags); recordLegacyBudgetCost(modelId, usage, response); + } else { + return withReconciliationResult(response, PricingReconciliationResult.UNPRICED); } return response; @@ -151,6 +171,7 @@ private ChatClientRequest resolvePricing(ChatClientRequest request) { PricingResolution resolution = snapshot.isPresent() ? PricingResolution.RESOLVED : PricingResolution.MISSING_PLAN; + rejectMissingPricingIfFailClosed(resolution); ChatClientRequest.Builder builder = request.mutate() .context(PRICING_POLICY_ID_CONTEXT, pricingPolicyId) @@ -159,6 +180,16 @@ private ChatClientRequest resolvePricing(ChatClientRequest request) { return builder.build(); } + private void rejectMissingPricingIfFailClosed(PricingResolution resolution) { + if (missingPricingPolicy != MissingPricingPolicy.FAIL_CLOSED) { + return; + } + if (resolution.isResolved()) { + return; + } + throw new MissingPricingException(resolution); + } + private String extractModelId(ChatClientRequest request) { Object value = request.context().get(MODEL_ID_CONTEXT); if (value instanceof String modelId && !modelId.isBlank()) { diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index 084c75e..b1de10a 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -9,6 +9,7 @@ import io.tokenpilot.budget.BudgetWindow; import io.tokenpilot.core.*; import io.tokenpilot.core.domain.*; +import io.tokenpilot.core.exception.MissingPricingException; import io.tokenpilot.springai.UsageExtractor; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -26,8 +27,10 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -276,12 +279,14 @@ void requireReconciliationWhenResponseModelDiffersFromSnapshotModel() { } @Test - @DisplayName("AI 호출 전 pricing resolution 실패는 provider 호출 여부 판단 값으로 전달되어야 한다") - void exposeMissingPricingResolutionBeforeProviderCall() { + @DisplayName("FAIL_OPEN은 missing pricing이어도 provider 호출을 허용하고 UNPRICED로 남겨야 한다") + void failOpenAllowsProviderCallAndMarksMissingPricingAsUnpriced() { LedgerManager ledgerManager = mock(LedgerManager.class); UsageExtractor extractor = mock(UsageExtractor.class); PricingRegistry pricingRegistry = mock(PricingRegistry.class); + TokenUsage usage = TokenUsage.from(100, 200); + when(extractor.extract(any())).thenReturn(usage); when(pricingRegistry.resolveSnapshot("missing-model", PricingPlan.DEFAULT_PRICING_POLICY_ID)) .thenReturn(Optional.empty()); @@ -304,8 +309,54 @@ void exposeMissingPricingResolutionBeforeProviderCall() { .isEqualTo(PricingResolution.MISSING_PLAN); assertThat(resolvedRequest.context()).doesNotContainKey(DefaultLedgerAdvisor.PRICING_SNAPSHOT_CONTEXT); - advisor.after(response("missing-model", resolvedRequest.context()), mock(AdvisorChain.class)); + ChatClientResponse unpricedResponse = advisor.after( + response("missing-model", resolvedRequest.context()), + mock(AdvisorChain.class) + ); + + assertThat(unpricedResponse.context().get(DefaultLedgerAdvisor.PRICING_RECONCILIATION_RESULT_CONTEXT)) + .isEqualTo(PricingReconciliationResult.UNPRICED); + + verify(pricingRegistry, times(1)).resolveSnapshot("missing-model", PricingPlan.DEFAULT_PRICING_POLICY_ID); + verifyNoMoreInteractions(pricingRegistry); + verifyNoInteractions(ledgerManager); + } + + @Test + @DisplayName("FAIL_CLOSED는 provider 호출 전에 missing pricing을 차단하고 invocation count를 0으로 유지해야 한다") + void failClosedBlocksMissingPricingBeforeProviderCall() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + AtomicInteger providerInvocationCount = new AtomicInteger(); + + when(pricingRegistry.resolveSnapshot("missing-model", PricingPlan.DEFAULT_PRICING_POLICY_ID)) + .thenReturn(Optional.empty()); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + null, + null, + mock(CostCalculator.class), + pricingRegistry, + MissingPricingPolicy.FAIL_CLOSED + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of(DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "missing-model") + ); + + assertThatThrownBy(() -> { + advisor.before(request, mock(AdvisorChain.class)); + providerInvocationCount.incrementAndGet(); + }) + .isInstanceOf(MissingPricingException.class) + .hasMessage("MISSING_PLAN") + .extracting(exception -> ((MissingPricingException) exception).getResolution()) + .isEqualTo(PricingResolution.MISSING_PLAN); + assertThat(providerInvocationCount).hasValue(0); verify(pricingRegistry, times(1)).resolveSnapshot("missing-model", PricingPlan.DEFAULT_PRICING_POLICY_ID); verifyNoMoreInteractions(pricingRegistry); verifyNoInteractions(ledgerManager); From 35176623309b99edb693dfefb0467f92dcbdda78 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 15:53:16 +0900 Subject: [PATCH 16/32] =?UTF-8?q?test(core):=20=EB=AA=85=EC=8B=9C=EC=A0=81?= =?UTF-8?q?=200=EC=9B=90=20pricing=EC=9D=98=20resolved=20=EC=A0=95?= =?UTF-8?q?=EC=82=B0=20=EA=B2=BD=EB=A1=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/DefaultLedgerManagerTest.java | 27 +++++++++ .../internal/DefaultLedgerAdvisorTest.java | 58 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultLedgerManagerTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultLedgerManagerTest.java index 21ddc44..ab7d258 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultLedgerManagerTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultLedgerManagerTest.java @@ -105,4 +105,31 @@ void shouldRecordWithSnapshotRatesAfterRegistryChanges() { event.cost().equals(cost) )); } + + @Test + @DisplayName("명시적 0 rate snapshot은 정상 0원 cost로 기록되어야 한다") + void shouldRecordZeroCostWithExplicitZeroRateSnapshot() { + PricingPlan freePlan = new PricingPlan( + "free-model", + BigDecimal.ZERO, + BigDecimal.ZERO, + Currency.getInstance("USD") + ); + PricingSnapshot snapshot = PricingSnapshot.from( + freePlan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + TokenUsage usage = TokenUsage.from(1000, 1000); + + Cost cost = manager.record(snapshot, usage, Map.of()); + + assertThat(cost.value()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(cost.currency()).isEqualTo(Currency.getInstance("USD")); + verify(listener).onRecord(argThat(event -> + event.modelId().equals("free-model") && + event.usage().equals(usage) && + event.cost().equals(cost) + )); + } } diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index b1de10a..4bbfb39 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -226,6 +226,64 @@ void reconcileActualWithPricingSnapshot() { verify(ledgerManager, never()).record(eq("gpt-4o"), same(usage), anyMap()); } + @Test + @DisplayName("explicit zero는 FAIL_CLOSED에서도 RESOLVED로 처리하고 0원 cost로 reconcile해야 한다") + void explicitZeroResolvesAndReconcilesWithZeroCost() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + TokenUsage usage = TokenUsage.from(100, 200); + PricingPlan plan = new PricingPlan( + "free-model", + BigDecimal.ZERO, + BigDecimal.ZERO, + Currency.getInstance("USD") + ); + PricingSnapshot snapshot = PricingSnapshot.from( + plan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + Cost zeroCost = Cost.zero(Currency.getInstance("USD")); + + when(extractor.extract(any())).thenReturn(usage); + when(pricingRegistry.resolveSnapshot("free-model", PricingPlan.DEFAULT_PRICING_POLICY_ID)) + .thenReturn(Optional.of(snapshot)); + when(ledgerManager.record(same(snapshot), same(usage), anyMap())).thenReturn(zeroCost); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + null, + null, + mock(CostCalculator.class), + pricingRegistry, + MissingPricingPolicy.FAIL_CLOSED + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of(DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "free-model") + ); + + ChatClientRequest resolvedRequest = advisor.before(request, mock(AdvisorChain.class)); + + assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT)) + .isEqualTo(PricingResolution.RESOLVED); + assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_SNAPSHOT_CONTEXT)) + .isSameAs(snapshot); + + ChatClientResponse reconciledResponse = advisor.after( + response("free-model", resolvedRequest.context()), + mock(AdvisorChain.class) + ); + + assertThat(reconciledResponse.context().get(DefaultLedgerAdvisor.PRICING_RECONCILIATION_RESULT_CONTEXT)) + .isEqualTo(PricingReconciliationResult.RECONCILED); + assertThat(reconciledResponse.context().get(DefaultLedgerAdvisor.PRICING_RECONCILIATION_RESULT_CONTEXT)) + .isNotEqualTo(PricingReconciliationResult.UNPRICED); + verify(ledgerManager, times(1)).record(same(snapshot), same(usage), anyMap()); + } + @Test @DisplayName("response model이 snapshot model과 다르면 기존 snapshot을 자동 적용하지 않아야 한다") void requireReconciliationWhenResponseModelDiffersFromSnapshotModel() { From c21db99de14a136762f82bf61865e92ad684aa11 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 15:56:45 +0900 Subject: [PATCH 17/32] =?UTF-8?q?test(core):=20missing=20pricing=EC=9D=98?= =?UTF-8?q?=20fail=20open=EA=B3=BC=20typed=20reason=20=EB=B3=B4=EC=A1=B4?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/DefaultLedgerAdvisorTest.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index 4bbfb39..c4d2249 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -380,6 +380,44 @@ void failOpenAllowsProviderCallAndMarksMissingPricingAsUnpriced() { verifyNoInteractions(ledgerManager); } + @Test + @DisplayName("missing pricing은 CostBound 실패로 전파할 PricingResolution을 보존해야 한다") + void preserveMissingPricingResolutionForCostBoundFailure() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + TokenUsage usage = TokenUsage.from(100, 200); + + when(extractor.extract(any())).thenReturn(usage); + when(pricingRegistry.resolveSnapshot("missing-model", PricingPlan.DEFAULT_PRICING_POLICY_ID)) + .thenReturn(Optional.empty()); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + null, + null, + mock(CostCalculator.class), + pricingRegistry + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of(DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "missing-model") + ); + ChatClientRequest resolvedRequest = advisor.before(request, mock(AdvisorChain.class)); + + ChatClientResponse unpricedResponse = advisor.after( + response("missing-model", resolvedRequest.context()), + mock(AdvisorChain.class) + ); + + assertThat(unpricedResponse.context().get(DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT)) + .isEqualTo(PricingResolution.MISSING_PLAN); + assertThat(unpricedResponse.context().get(DefaultLedgerAdvisor.PRICING_RECONCILIATION_RESULT_CONTEXT)) + .isEqualTo(PricingReconciliationResult.UNPRICED); + verifyNoInteractions(ledgerManager); + } + @Test @DisplayName("FAIL_CLOSED는 provider 호출 전에 missing pricing을 차단하고 invocation count를 0으로 유지해야 한다") void failClosedBlocksMissingPricingBeforeProviderCall() { From 212cd87eb6e28244c03d363359eff88080507145 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 16:57:29 +0900 Subject: [PATCH 18/32] =?UTF-8?q?feat(core):=20FAIL=5FCLOSED=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=88=84=EB=9D=BD=20rate=EC=9D=98=20provider=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=B0=A8=EB=8B=A8=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/DefaultLedgerAdvisor.java | 52 ++++++++++++++++-- .../internal/DefaultLedgerAdvisorTest.java | 53 +++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index 353d42c..2f416ac 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -31,6 +31,7 @@ public class DefaultLedgerAdvisor implements LedgerAdvisor { static final String PRICING_SNAPSHOT_CONTEXT = "tokenpilot.pricing.snapshot"; static final String PRICING_RESOLUTION_CONTEXT = "tokenpilot.pricing.resolution"; static final String PRICING_RECONCILIATION_RESULT_CONTEXT = "tokenpilot.pricing.reconciliation.result"; + static final String REQUIRED_TOKEN_TYPE_CONTEXT = "tokenpilot.pricing.required.token.type"; private final LedgerManager ledgerManager; private final UsageExtractor usageExtractor; @@ -168,18 +169,53 @@ private ChatClientRequest resolvePricing(ChatClientRequest request) { String pricingPolicyId = extractPricingPolicyId(request); Optional snapshot = pricingRegistry.resolveSnapshot(modelId, pricingPolicyId); - PricingResolution resolution = snapshot.isPresent() - ? PricingResolution.RESOLVED - : PricingResolution.MISSING_PLAN; + PricingResolution resolution = resolvePricingResolution(request, snapshot); rejectMissingPricingIfFailClosed(resolution); + return withPricingContext(request, pricingPolicyId, resolution, snapshot); + } + + private PricingResolution resolvePricingResolution( + ChatClientRequest request, + Optional snapshot + ) { + if (snapshot.isEmpty()) { + return PricingResolution.MISSING_PLAN; + } + + Optional requiredTokenType = extractRequiredTokenType(request); + if (requiredTokenType.isEmpty()) { + return PricingResolution.RESOLVED; + } + + return resolveSnapshotRate(snapshot.get(), requiredTokenType.get()); + } + + private ChatClientRequest withPricingContext( + ChatClientRequest request, + String pricingPolicyId, + PricingResolution resolution, + Optional snapshot + ) { ChatClientRequest.Builder builder = request.mutate() .context(PRICING_POLICY_ID_CONTEXT, pricingPolicyId) .context(PRICING_RESOLUTION_CONTEXT, resolution); - snapshot.ifPresent(value -> builder.context(PRICING_SNAPSHOT_CONTEXT, value)); + if (resolution.isResolved()) { + snapshot.ifPresent(value -> builder.context(PRICING_SNAPSHOT_CONTEXT, value)); + } return builder.build(); } + private PricingResolution resolveSnapshotRate(PricingSnapshot snapshot, TokenType tokenType) { + PricingPlan plan = new PricingPlan( + snapshot.modelId(), + snapshot.pricingPolicyId(), + snapshot.rates(), + snapshot.currency() + ); + return plan.resolveRate(tokenType); + } + private void rejectMissingPricingIfFailClosed(PricingResolution resolution) { if (missingPricingPolicy != MissingPricingPolicy.FAIL_CLOSED) { return; @@ -236,6 +272,14 @@ private String extractPricingPolicyId(ChatClientRequest request) { return PricingPlan.DEFAULT_PRICING_POLICY_ID; } + private Optional extractRequiredTokenType(ChatClientRequest request) { + Object value = request.context().get(REQUIRED_TOKEN_TYPE_CONTEXT); + if (value instanceof TokenType tokenType) { + return Optional.of(tokenType); + } + return Optional.empty(); + } + private Optional extractPricingSnapshot(ChatClientResponse response) { Map context = response.context(); Object value = null; diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index c4d2249..0fde7fb 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -458,6 +458,59 @@ void failClosedBlocksMissingPricingBeforeProviderCall() { verifyNoInteractions(ledgerManager); } + @Test + @DisplayName("FAIL_CLOSED는 provider 호출 전에 MISSING_RATE를 차단하고 invocation count를 0으로 유지해야 한다") + void failClosedBlocksMissingRateBeforeProviderCall() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + AtomicInteger providerInvocationCount = new AtomicInteger(); + PricingPlan plan = new PricingPlan( + "prompt-only-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.01")), + Currency.getInstance("USD") + ); + PricingSnapshot snapshot = PricingSnapshot.from( + plan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + + when(pricingRegistry.resolveSnapshot("prompt-only-model", PricingPlan.DEFAULT_PRICING_POLICY_ID)) + .thenReturn(Optional.of(snapshot)); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + null, + null, + mock(CostCalculator.class), + pricingRegistry, + MissingPricingPolicy.FAIL_CLOSED + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of( + DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "prompt-only-model", + DefaultLedgerAdvisor.REQUIRED_TOKEN_TYPE_CONTEXT, TokenType.COMPLETION + ) + ); + + assertThatThrownBy(() -> { + advisor.before(request, mock(AdvisorChain.class)); + providerInvocationCount.incrementAndGet(); + }) + .isInstanceOf(MissingPricingException.class) + .hasMessage("MISSING_RATE") + .extracting(exception -> ((MissingPricingException) exception).getResolution()) + .isEqualTo(PricingResolution.MISSING_RATE); + + assertThat(providerInvocationCount).hasValue(0); + verify(pricingRegistry, times(1)).resolveSnapshot("prompt-only-model", PricingPlan.DEFAULT_PRICING_POLICY_ID); + verifyNoMoreInteractions(pricingRegistry); + verifyNoInteractions(ledgerManager); + } + @Test @DisplayName("AI 호출 전 BudgetEvaluator를 통해 예산을 체크해야 한다") void checkBudgetBeforeAIRequest() { From 1d1e80e96a6677b55a762271296a44bbc4a647ec Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 16:59:43 +0900 Subject: [PATCH 19/32] =?UTF-8?q?test(core):=20currency=20mismatch=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=AC=B4=EB=B3=80=EA=B2=BD=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/InMemoryPricingRegistryTest.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java index 9114fda..10dfd35 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java @@ -184,6 +184,37 @@ void shouldResolveCurrencyMismatchWhenExpectedCurrencyDiffers() { assertThat(resolution.isResolved()).isFalse(); } + @Test + @DisplayName("currency mismatch는 등록된 pricing 상태를 변경하지 않아야 한다") + void shouldNotChangePricingStateWhenCurrencyMismatches() { + PricingPlan plan = new PricingPlan( + "usd-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.015")), + Currency.getInstance("USD") + ); + registry.registerPlan(plan); + + PricingResolution resolution = registry.resolveRate( + "usd-model", + TokenType.PROMPT, + Currency.getInstance("KRW") + ); + + assertThat(resolution).isEqualTo(PricingResolution.CURRENCY_MISMATCH); + assertThat(registry.getPlan("usd-model")).contains(plan); + assertThat(registry.resolveSnapshot("usd-model", PricingPlan.DEFAULT_PRICING_POLICY_ID)) + .isPresent() + .get() + .satisfies(snapshot -> { + assertThat(snapshot.modelId()).isEqualTo("usd-model"); + assertThat(snapshot.pricingPolicyId()).isEqualTo(PricingPlan.DEFAULT_PRICING_POLICY_ID); + assertThat(snapshot.currency()).isEqualTo(Currency.getInstance("USD")); + assertThat(snapshot.rates()).containsEntry(TokenType.PROMPT, new BigDecimal("0.015")); + }); + assertThat(registry.resolveRate("usd-model", TokenType.PROMPT, Currency.getInstance("USD"))) + .isEqualTo(PricingResolution.RESOLVED); + } + @Test @DisplayName("기대 통화와 plan 통화가 같으면 일반 rate resolution을 수행해야 한다") void shouldDelegateRateResolutionWhenExpectedCurrencyMatches() { From 3cea0059b8d4ec5019f0df3addcfd24756f0dca8 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 17:08:36 +0900 Subject: [PATCH 20/32] =?UTF-8?q?test(core):=20pricing=20miss=20reason=20l?= =?UTF-8?q?ow-cardinality=20=EA=B0=92=20=EC=8B=9D=EB=B3=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/domain/PricingResolutionTest.java | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java index 300a8cf..b456c18 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingResolutionTest.java @@ -60,9 +60,28 @@ void resolutionItselfIsState() { @Test @DisplayName("PricingResolution 자체가 low-cardinality pricing miss reason이다") void resolutionItselfIsLowCardinalityReason() { - assertThat(PricingResolution.MISSING_PLAN.name()).isEqualTo("MISSING_PLAN"); - assertThat(PricingResolution.MISSING_RATE.name()).isEqualTo("MISSING_RATE"); - assertThat(PricingResolution.CURRENCY_MISMATCH.name()).isEqualTo("CURRENCY_MISMATCH"); + assertThat(PricingResolution.values()) + .filteredOn(resolution -> !resolution.isResolved()) + .extracting(PricingResolution::name) + .containsExactly( + "MISSING_PLAN", + "MISSING_RATE", + "CURRENCY_MISMATCH" + ); + } + + @Test + @DisplayName("pricing miss reason은 model/tenant/user id를 포함하지 않는다") + void pricingMissReasonDoesNotContainHighCardinalityIdentifiers() { + assertThat(PricingResolution.values()) + .filteredOn(resolution -> !resolution.isResolved()) + .extracting(PricingResolution::name) + .allSatisfy(reason -> assertThat(reason) + .doesNotContain("gpt") + .doesNotContain("model") + .doesNotContain("tenant") + .doesNotContain("user") + .doesNotContain("policy")); } @Test From c2d81456bf6d757ff476d21909fb75de2e871684 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 17:11:20 +0900 Subject: [PATCH 21/32] =?UTF-8?q?test(core):=20=EB=8F=99=EC=9D=BC=20pricin?= =?UTF-8?q?g=20policy=20snapshot=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/InMemoryPricingRegistryTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java index 10dfd35..c969c74 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/InMemoryPricingRegistryTest.java @@ -103,6 +103,31 @@ void shouldGetSnapshotByModelIdAndPricingPolicyId() { assertThat(snapshot.get().rates()).containsEntry(TokenType.PROMPT, new BigDecimal("0.0025")); } + @Test + @DisplayName("#32가 같은 model id로 resolve한 입력은 동일 pricing policy snapshot을 사용해야 한다") + void shouldUseSameSnapshotWhenResolvedModelIdIsSame() { + String modelId = "gpt-4o-2024-08-06"; + String aliasResolvedModelId = modelId; + String canonicalModelId = modelId; + String pricingPolicyId = "standard"; + PricingPlan plan = new PricingPlan( + modelId, + pricingPolicyId, + Map.of(TokenType.PROMPT, new BigDecimal("0.0025")), + Currency.getInstance("USD") + ); + registry.registerPlan(plan); + + PricingSnapshot aliasSnapshot = registry.resolveSnapshot(aliasResolvedModelId, pricingPolicyId).orElseThrow(); + PricingSnapshot canonicalSnapshot = registry.resolveSnapshot(canonicalModelId, pricingPolicyId).orElseThrow(); + + assertThat(aliasSnapshot.modelId()).isEqualTo(canonicalSnapshot.modelId()); + assertThat(aliasSnapshot.pricingPolicyId()).isEqualTo(canonicalSnapshot.pricingPolicyId()); + assertThat(aliasSnapshot.catalogVersion()).isEqualTo(canonicalSnapshot.catalogVersion()); + assertThat(aliasSnapshot.currency()).isEqualTo(canonicalSnapshot.currency()); + assertThat(aliasSnapshot.rates()).containsAllEntriesOf(canonicalSnapshot.rates()); + } + @Test @DisplayName("registry 변경 후 새 요청은 변경된 pricing plan으로 snapshot을 resolve해야 한다") void shouldResolveNewSnapshotAfterRegistryChanges() { From a4026ca7cc13b3bf52d6481264235c2836e5fe0d Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 17:14:25 +0900 Subject: [PATCH 22/32] =?UTF-8?q?feat(core):=20budget/preflight=EC=9D=98?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20missing=20policy=EB=8A=94=20FAIL=5FCLOS?= =?UTF-8?q?ED=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokenPilotAutoConfiguration.java | 4 ++- .../TokenPilotAutoConfigurationTest.java | 26 +++++++++++++++++++ .../internal/LedgerSpringAiComponents.java | 24 ++++++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java b/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java index ac1ac6a..421b5c1 100644 --- a/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java +++ b/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java @@ -9,6 +9,7 @@ import io.tokenpilot.core.LedgerManager; 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; @@ -120,7 +121,8 @@ public LedgerAdvisor ledgerAdvisor( evaluator, stateStore, costCalculator, - pricingRegistry + pricingRegistry, + MissingPricingPolicy.FAIL_CLOSED ); } diff --git a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java index 9698ad7..730983e 100644 --- a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java +++ b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java @@ -19,6 +19,7 @@ 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; @@ -50,6 +51,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; @@ -264,6 +266,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() { diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/LedgerSpringAiComponents.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/LedgerSpringAiComponents.java index e794ec5..dfc6fa2 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/LedgerSpringAiComponents.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/LedgerSpringAiComponents.java @@ -5,6 +5,7 @@ import io.tokenpilot.core.CostCalculator; import io.tokenpilot.core.LedgerManager; import io.tokenpilot.core.PricingRegistry; +import io.tokenpilot.core.domain.MissingPricingPolicy; import io.tokenpilot.springai.LedgerAdvisor; import io.tokenpilot.springai.UsageExtractor; @@ -34,6 +35,26 @@ public static LedgerAdvisor defaultLedgerAdvisor( BudgetStateStore budgetStateStore, CostCalculator costCalculator, PricingRegistry pricingRegistry + ) { + return defaultLedgerAdvisor( + ledgerManager, + usageExtractor, + budgetEvaluator, + budgetStateStore, + costCalculator, + pricingRegistry, + MissingPricingPolicy.FAIL_OPEN + ); + } + + public static LedgerAdvisor defaultLedgerAdvisor( + LedgerManager ledgerManager, + UsageExtractor usageExtractor, + BudgetEvaluator budgetEvaluator, + BudgetStateStore budgetStateStore, + CostCalculator costCalculator, + PricingRegistry pricingRegistry, + MissingPricingPolicy missingPricingPolicy ) { return new DefaultLedgerAdvisor( ledgerManager, @@ -41,7 +62,8 @@ public static LedgerAdvisor defaultLedgerAdvisor( budgetEvaluator, budgetStateStore, costCalculator, - pricingRegistry + pricingRegistry, + missingPricingPolicy ); } } From fede72350884f6bffb2f1aad87fa565e990a367f Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 17:21:05 +0900 Subject: [PATCH 23/32] =?UTF-8?q?feat(core):=20missing=20pricing=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EA=B2=BD=EA=B3=84=EC=99=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tokenpilot/springai/internal/DefaultLedgerAdvisor.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index 2f416ac..860bdf3 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -14,6 +14,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.Optional; /** @@ -69,7 +70,10 @@ public DefaultLedgerAdvisor(LedgerManager ledgerManager, UsageExtractor usageExt this.budgetStateStore = budgetStateStore; this.costCalculator = costCalculator; this.pricingRegistry = pricingRegistry; - this.missingPricingPolicy = missingPricingPolicy; + this.missingPricingPolicy = Objects.requireNonNull( + missingPricingPolicy, + "missingPricingPolicy must not be null" + ); } @Override From 0c038e4843b01fedc69d1e18e59bbaa33c332f76 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 17:43:17 +0900 Subject: [PATCH 24/32] =?UTF-8?q?fix(core):=20FAIL=5FCLOSED=EC=9D=98=20mod?= =?UTF-8?q?el=20id=20=EB=88=84=EB=9D=BD=20=EC=B0=A8=EB=8B=A8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/DefaultLedgerAdvisor.java | 1 + .../internal/DefaultLedgerAdvisorTest.java | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index 860bdf3..386dc22 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -168,6 +168,7 @@ private void recordLegacyBudgetCost(String modelId, TokenUsage usage, ChatClient private ChatClientRequest resolvePricing(ChatClientRequest request) { String modelId = extractModelId(request); if (modelId == null) { + rejectMissingPricingIfFailClosed(PricingResolution.MISSING_PLAN); return request; } diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index 0fde7fb..12f2876 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -458,6 +458,42 @@ void failClosedBlocksMissingPricingBeforeProviderCall() { verifyNoInteractions(ledgerManager); } + @Test + @DisplayName("FAIL_CLOSED는 model id가 없으면 provider 호출 전에 차단하고 invocation count를 0으로 유지해야 한다") + void failClosedBlocksMissingModelIdBeforeProviderCall() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + AtomicInteger providerInvocationCount = new AtomicInteger(); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + null, + null, + mock(CostCalculator.class), + pricingRegistry, + MissingPricingPolicy.FAIL_CLOSED + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of() + ); + + assertThatThrownBy(() -> { + advisor.before(request, mock(AdvisorChain.class)); + providerInvocationCount.incrementAndGet(); + }) + .isInstanceOf(MissingPricingException.class) + .hasMessage("MISSING_PLAN") + .extracting(exception -> ((MissingPricingException) exception).getResolution()) + .isEqualTo(PricingResolution.MISSING_PLAN); + + assertThat(providerInvocationCount).hasValue(0); + verifyNoInteractions(pricingRegistry); + verifyNoInteractions(ledgerManager); + } + @Test @DisplayName("FAIL_CLOSED는 provider 호출 전에 MISSING_RATE를 차단하고 invocation count를 0으로 유지해야 한다") void failClosedBlocksMissingRateBeforeProviderCall() { From a160f415f81cee3960dec27c73a4ca6b87083052 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 17:46:36 +0900 Subject: [PATCH 25/32] =?UTF-8?q?fix(core):=20=EB=B9=88=20pricing=20rates?= =?UTF-8?q?=20=EB=B3=B5=EC=82=AC=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/tokenpilot/core/domain/PricingPlan.java | 6 +++++- .../tokenpilot/core/domain/PricingSnapshot.java | 5 ++++- .../tokenpilot/core/domain/PricingPlanTest.java | 12 ++++++++++++ .../core/domain/PricingSnapshotTest.java | 15 +++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java index cddefbc..9de0d72 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingPlan.java @@ -5,6 +5,7 @@ import java.util.Currency; import java.util.EnumMap; import java.util.Map; +import java.util.Objects; /** * 특정 모델의 가격 정책 정보. @@ -27,7 +28,10 @@ public record PricingPlan( throw new IllegalArgumentException("pricingPolicyId must not be blank"); } - rates = Collections.unmodifiableMap(new EnumMap<>(rates)); + Objects.requireNonNull(rates, "rates must not be null"); + Map copiedRates = new EnumMap<>(TokenType.class); + copiedRates.putAll(rates); + rates = Collections.unmodifiableMap(copiedRates); if (currency == null) { currency = Currency.getInstance("USD"); } diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingSnapshot.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingSnapshot.java index 3b50116..3d71c4c 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingSnapshot.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/PricingSnapshot.java @@ -34,7 +34,10 @@ public record PricingSnapshot( checkedAt = Objects.requireNonNull(checkedAt, "checkedAt must not be null"); currency = Objects.requireNonNull(currency, "currency must not be null"); - rates = Collections.unmodifiableMap(new EnumMap<>(rates)); + Objects.requireNonNull(rates, "rates must not be null"); + Map copiedRates = new EnumMap<>(TokenType.class); + copiedRates.putAll(rates); + rates = Collections.unmodifiableMap(copiedRates); rates.values().forEach(rate -> { if (rate.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("rate must not be negative"); diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java index 27974b9..1baaade 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingPlanTest.java @@ -154,4 +154,16 @@ void resolveFallbackFromExplicitZeroBaseRate() { assertThat(plan.resolveRate(TokenType.CACHE_READ_PROMPT)).isEqualTo(PricingResolution.RESOLVED); assertThat(plan.resolveRate(TokenType.CACHE_CREATION_PROMPT)).isEqualTo(PricingResolution.RESOLVED); } + + @Test + @DisplayName("빈 rates plan은 생성 가능하고 필요한 rate를 MISSING_RATE로 표현한다") + void emptyRatesResolveMissingRate() { + PricingPlan plan = new PricingPlan( + "empty-rates-model", + Map.of(), + Currency.getInstance("USD") + ); + + assertThat(plan.resolveRate(TokenType.PROMPT)).isEqualTo(PricingResolution.MISSING_RATE); + } } diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingSnapshotTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingSnapshotTest.java index 7156ebf..0245eb2 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingSnapshotTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/PricingSnapshotTest.java @@ -61,4 +61,19 @@ void ratesMustBeImmutable() { assertThatThrownBy(() -> snapshot.rates().put(TokenType.COMPLETION, new BigDecimal("0.03"))) .isInstanceOf(UnsupportedOperationException.class); } + + @Test + @DisplayName("pricing snapshot은 빈 rates를 보존할 수 있어야 한다") + void preserveEmptyRates() { + PricingSnapshot snapshot = new PricingSnapshot( + "gpt-4o", + "standard", + "catalog-v1", + Instant.parse("2026-07-30T00:00:00Z"), + Map.of(), + Currency.getInstance("USD") + ); + + assertThat(snapshot.rates()).isEmpty(); + } } From 38da265e18bfb75a0704abb099f6b7a925e7dcce Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 17:53:39 +0900 Subject: [PATCH 26/32] =?UTF-8?q?test(autoconfigure):=20budget=20advisor?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20model=20id=EC=99=80?= =?UTF-8?q?=20pricing=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokenPilotAutoConfigurationTest.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java index 730983e..36e1bca 100644 --- a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java +++ b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java @@ -245,14 +245,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)); From 63fc0681b1f54953ed0b36f70ee6c858c78767d4 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 30 Jul 2026 17:56:12 +0900 Subject: [PATCH 27/32] =?UTF-8?q?fix(core):=20FAIL=5FOPEN=20model=20id=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=8B=9C=20pricing=20context=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/DefaultLedgerAdvisor.java | 10 ++++- .../internal/DefaultLedgerAdvisorTest.java | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index 386dc22..884dc8d 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -168,8 +168,14 @@ private void recordLegacyBudgetCost(String modelId, TokenUsage usage, ChatClient private ChatClientRequest resolvePricing(ChatClientRequest request) { String modelId = extractModelId(request); if (modelId == null) { - rejectMissingPricingIfFailClosed(PricingResolution.MISSING_PLAN); - return request; + PricingResolution resolution = PricingResolution.MISSING_PLAN; + rejectMissingPricingIfFailClosed(resolution); + return withPricingContext( + request, + extractPricingPolicyId(request), + resolution, + Optional.empty() + ); } String pricingPolicyId = extractPricingPolicyId(request); diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index 12f2876..b3634da 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -380,6 +380,46 @@ void failOpenAllowsProviderCallAndMarksMissingPricingAsUnpriced() { verifyNoInteractions(ledgerManager); } + @Test + @DisplayName("FAIL_OPEN은 model id가 없어도 MISSING_PLAN을 보존하고 UNPRICED로 남겨야 한다") + void failOpenPreservesMissingPlanWhenModelIdIsMissing() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + TokenUsage usage = TokenUsage.from(100, 200); + + when(extractor.extract(any())).thenReturn(usage); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + null, + null, + mock(CostCalculator.class), + pricingRegistry + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of() + ); + + ChatClientRequest resolvedRequest = advisor.before(request, mock(AdvisorChain.class)); + + assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT)) + .isEqualTo(PricingResolution.MISSING_PLAN); + assertThat(resolvedRequest.context()).doesNotContainKey(DefaultLedgerAdvisor.PRICING_SNAPSHOT_CONTEXT); + + ChatClientResponse unpricedResponse = advisor.after( + response("unknown-model", resolvedRequest.context()), + mock(AdvisorChain.class) + ); + + assertThat(unpricedResponse.context().get(DefaultLedgerAdvisor.PRICING_RECONCILIATION_RESULT_CONTEXT)) + .isEqualTo(PricingReconciliationResult.UNPRICED); + verifyNoInteractions(pricingRegistry); + verifyNoInteractions(ledgerManager); + } + @Test @DisplayName("missing pricing은 CostBound 실패로 전파할 PricingResolution을 보존해야 한다") void preserveMissingPricingResolutionForCostBoundFailure() { From 1324ce1255ed047ef77d32b7a9c1ee560ddc74d7 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Mon, 3 Aug 2026 23:50:52 +0900 Subject: [PATCH 28/32] =?UTF-8?q?fix(spring-ai):=20provider=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=A0=84=20=EB=B6=80=EB=B6=84=20pricing=20snapshot?= =?UTF-8?q?=EC=9D=84=20MISSING=5FRATE=EB=A1=9C=20=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/DefaultLedgerAdvisor.java | 53 ++++++++----------- .../internal/DefaultLedgerAdvisorTest.java | 47 ++++++++++++++-- 2 files changed, 64 insertions(+), 36 deletions(-) diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index 884dc8d..a63792d 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -32,7 +32,6 @@ public class DefaultLedgerAdvisor implements LedgerAdvisor { static final String PRICING_SNAPSHOT_CONTEXT = "tokenpilot.pricing.snapshot"; static final String PRICING_RESOLUTION_CONTEXT = "tokenpilot.pricing.resolution"; static final String PRICING_RECONCILIATION_RESULT_CONTEXT = "tokenpilot.pricing.reconciliation.result"; - static final String REQUIRED_TOKEN_TYPE_CONTEXT = "tokenpilot.pricing.required.token.type"; private final LedgerManager ledgerManager; private final UsageExtractor usageExtractor; @@ -180,26 +179,34 @@ private ChatClientRequest resolvePricing(ChatClientRequest request) { String pricingPolicyId = extractPricingPolicyId(request); Optional snapshot = pricingRegistry.resolveSnapshot(modelId, pricingPolicyId); - PricingResolution resolution = resolvePricingResolution(request, snapshot); + PricingResolution resolution = resolvePricingResolution(snapshot); rejectMissingPricingIfFailClosed(resolution); return withPricingContext(request, pricingPolicyId, resolution, snapshot); } - private PricingResolution resolvePricingResolution( - ChatClientRequest request, - Optional snapshot - ) { + private PricingResolution resolvePricingResolution(Optional snapshot) { if (snapshot.isEmpty()) { return PricingResolution.MISSING_PLAN; } - Optional requiredTokenType = extractRequiredTokenType(request); - if (requiredTokenType.isEmpty()) { - return PricingResolution.RESOLVED; + return resolveRequiredRates(snapshot.get()); + } + + private PricingResolution resolveRequiredRates(PricingSnapshot snapshot) { + PricingPlan plan = new PricingPlan( + snapshot.modelId(), + snapshot.pricingPolicyId(), + snapshot.rates(), + snapshot.currency() + ); + + PricingResolution promptResolution = plan.resolveRate(TokenType.PROMPT); + if (!promptResolution.isResolved()) { + return promptResolution; } - return resolveSnapshotRate(snapshot.get(), requiredTokenType.get()); + return plan.resolveRate(TokenType.COMPLETION); } private ChatClientRequest withPricingContext( @@ -217,16 +224,6 @@ private ChatClientRequest withPricingContext( return builder.build(); } - private PricingResolution resolveSnapshotRate(PricingSnapshot snapshot, TokenType tokenType) { - PricingPlan plan = new PricingPlan( - snapshot.modelId(), - snapshot.pricingPolicyId(), - snapshot.rates(), - snapshot.currency() - ); - return plan.resolveRate(tokenType); - } - private void rejectMissingPricingIfFailClosed(PricingResolution resolution) { if (missingPricingPolicy != MissingPricingPolicy.FAIL_CLOSED) { return; @@ -283,14 +280,6 @@ private String extractPricingPolicyId(ChatClientRequest request) { return PricingPlan.DEFAULT_PRICING_POLICY_ID; } - private Optional extractRequiredTokenType(ChatClientRequest request) { - Object value = request.context().get(REQUIRED_TOKEN_TYPE_CONTEXT); - if (value instanceof TokenType tokenType) { - return Optional.of(tokenType); - } - return Optional.empty(); - } - private Optional extractPricingSnapshot(ChatClientResponse response) { Map context = response.context(); Object value = null; @@ -335,11 +324,11 @@ private Map extractTags(Map context) { return tags; } - context.forEach((k, v) -> { - if (v instanceof String s) { - tags.put(k, s); + for (Map.Entry contextEntry : context.entrySet()) { + if (contextEntry.getValue() instanceof String tagValue) { + tags.put(contextEntry.getKey(), tagValue); } - }); + } return tags; } } diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index b3634da..eabf4ba 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -172,6 +172,48 @@ void createPricingSnapshotBeforeProviderCall() { verifyNoInteractions(ledgerManager); } + @Test + @DisplayName("AI 호출 전 completion rate가 없는 부분 snapshot은 MISSING_RATE여야 한다") + void resolvePartialPricingSnapshotAsMissingRateBeforeProviderCall() { + LedgerManager ledgerManager = mock(LedgerManager.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + PricingPlan plan = new PricingPlan( + "prompt-only-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.01")), + Currency.getInstance("USD") + ); + PricingSnapshot snapshot = PricingSnapshot.from( + plan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + + when(pricingRegistry.resolveSnapshot("prompt-only-model", PricingPlan.DEFAULT_PRICING_POLICY_ID)) + .thenReturn(Optional.of(snapshot)); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + mock(UsageExtractor.class), + null, + null, + mock(CostCalculator.class), + pricingRegistry + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of(DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "prompt-only-model") + ); + + ChatClientRequest resolvedRequest = advisor.before(request, mock(AdvisorChain.class)); + + assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT)) + .isEqualTo(PricingResolution.MISSING_RATE); + verify(pricingRegistry, times(1)) + .resolveSnapshot("prompt-only-model", PricingPlan.DEFAULT_PRICING_POLICY_ID); + verifyNoMoreInteractions(pricingRegistry); + verifyNoInteractions(ledgerManager); + } + @Test @DisplayName("AI 응답 후 actual reconciliation은 registry를 다시 조회하지 않고 snapshot으로 기록해야 한다") void reconcileActualWithPricingSnapshot() { @@ -566,10 +608,7 @@ void failClosedBlocksMissingRateBeforeProviderCall() { ); ChatClientRequest request = new ChatClientRequest( new Prompt("test"), - Map.of( - DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "prompt-only-model", - DefaultLedgerAdvisor.REQUIRED_TOKEN_TYPE_CONTEXT, TokenType.COMPLETION - ) + Map.of(DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "prompt-only-model") ); assertThatThrownBy(() -> { From 0a33ed941efaeb157dfae6a3b22e2f41409cf65c Mon Sep 17 00:00:00 2001 From: rigu1 Date: Tue, 4 Aug 2026 00:12:04 +0900 Subject: [PATCH 29/32] =?UTF-8?q?fix(pricing):=20actual=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=EC=9D=98=20=EB=88=84=EB=9D=BD=20rate?= =?UTF-8?q?=EB=A5=BC=200=EC=9B=90=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EC=82=B0?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/internal/DefaultCostCalculator.java | 22 +++-- .../internal/DefaultCostCalculatorTest.java | 34 +++++++ .../internal/DefaultLedgerAdvisor.java | 24 ++++- .../internal/DefaultLedgerAdvisorTest.java | 91 +++++++++++++++++++ 4 files changed, 163 insertions(+), 8 deletions(-) diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultCostCalculator.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultCostCalculator.java index 7b40429..1f8e1fc 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultCostCalculator.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultCostCalculator.java @@ -3,8 +3,10 @@ import io.tokenpilot.core.CostCalculator; import io.tokenpilot.core.domain.Cost; import io.tokenpilot.core.domain.PricingPlan; +import io.tokenpilot.core.domain.PricingResolution; import io.tokenpilot.core.domain.TokenType; import io.tokenpilot.core.domain.TokenUsage; +import io.tokenpilot.core.exception.MissingPricingException; import java.math.BigDecimal; @@ -24,21 +26,27 @@ public Cost calculate(TokenUsage usage, PricingPlan plan) { long regularInput = usage.inputTokens() - cacheReadInput - cacheCreationInput; long regularOutput = usage.outputTokens() - reasoningOutput; - BigDecimal totalCostValue = costFor(regularInput, plan.getRate(TokenType.PROMPT)) - .add(costFor(cacheReadInput, plan.getRate(TokenType.CACHE_READ_PROMPT))) - .add(costFor(cacheCreationInput, plan.getRate(TokenType.CACHE_CREATION_PROMPT))) - .add(costFor(regularOutput, plan.getRate(TokenType.COMPLETION))) - .add(costFor(reasoningOutput, plan.getRate(TokenType.REASONING))); + BigDecimal totalCostValue = costFor(regularInput, plan, TokenType.PROMPT) + .add(costFor(cacheReadInput, plan, TokenType.CACHE_READ_PROMPT)) + .add(costFor(cacheCreationInput, plan, TokenType.CACHE_CREATION_PROMPT)) + .add(costFor(regularOutput, plan, TokenType.COMPLETION)) + .add(costFor(reasoningOutput, plan, TokenType.REASONING)); return new Cost(totalCostValue, plan.currency()); } - private BigDecimal costFor(long count, BigDecimal rate) { + private BigDecimal costFor(long count, PricingPlan plan, TokenType tokenType) { if (count == 0) { return BigDecimal.ZERO; } - return rate.multiply(BigDecimal.valueOf(count)) + PricingResolution resolution = plan.resolveRate(tokenType); + if (!resolution.isResolved()) { + throw new MissingPricingException(resolution); + } + + return plan.getRate(tokenType) + .multiply(BigDecimal.valueOf(count)) .movePointLeft(3); } diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java index 4fbe16f..34489fe 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java @@ -2,10 +2,12 @@ import io.tokenpilot.core.domain.Cost; import io.tokenpilot.core.domain.PricingPlan; +import io.tokenpilot.core.domain.PricingResolution; import io.tokenpilot.core.domain.TokenType; import io.tokenpilot.core.domain.TokenUsage; import io.tokenpilot.core.domain.TokenUsageDetails; import io.tokenpilot.core.domain.UsageSource; +import io.tokenpilot.core.exception.MissingPricingException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,6 +16,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class DefaultCostCalculatorTest { @@ -90,4 +93,35 @@ void calculateOneThousandTokensAtRate() { assertThat(cost.value()).isEqualByComparingTo("0.0004"); } + + @Test + @DisplayName("실제 completion 사용량에 필요한 rate가 없으면 MISSING_RATE여야 한다") + void failWhenActualCompletionRateIsMissing() { + PricingPlan plan = new PricingPlan( + "prompt-only-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.01")), + Currency.getInstance("USD") + ); + TokenUsage usage = TokenUsage.from(1_000, 1_000); + + assertThatThrownBy(() -> calculator.calculate(usage, plan)) + .isInstanceOf(MissingPricingException.class) + .extracting(exception -> ((MissingPricingException) exception).getResolution()) + .isEqualTo(PricingResolution.MISSING_RATE); + } + + @Test + @DisplayName("실제 사용량이 없는 token type의 누락 rate는 계산을 실패시키지 않아야 한다") + void doNotRequireRateWhenActualUsageIsZero() { + PricingPlan plan = new PricingPlan( + "prompt-only-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.01")), + Currency.getInstance("USD") + ); + TokenUsage usage = TokenUsage.from(1_000, 0); + + Cost cost = calculator.calculate(usage, plan); + + assertThat(cost.value()).isEqualByComparingTo("0.01"); + } } diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index a63792d..89100c6 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -109,7 +109,12 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) return withReconciliationResult(response, PricingReconciliationResult.RECONCILIATION_REQUIRED); } - Cost cost = ledgerManager.record(snapshot.get(), usage, tags); + Cost cost; + try { + cost = ledgerManager.record(snapshot.get(), usage, tags); + } catch (MissingPricingException exception) { + return handleActualPricingFailure(response, exception); + } ChatClientResponse reconciledResponse = withReconciliationResult( response, PricingReconciliationResult.RECONCILED @@ -133,6 +138,23 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) return response; } + private ChatClientResponse handleActualPricingFailure( + ChatClientResponse response, + MissingPricingException exception + ) { + if (missingPricingPolicy == MissingPricingPolicy.FAIL_CLOSED) { + throw exception; + } + + Map context = new HashMap<>(); + if (response.context() != null) { + context.putAll(response.context()); + } + context.put(PRICING_RESOLUTION_CONTEXT, exception.getResolution()); + context.put(PRICING_RECONCILIATION_RESULT_CONTEXT, PricingReconciliationResult.UNPRICED); + return new ChatClientResponse(response.chatResponse(), context); + } + private ChatClientResponse withReconciliationResult( ChatClientResponse response, PricingReconciliationResult result diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index eabf4ba..ed13e59 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -10,6 +10,7 @@ import io.tokenpilot.core.*; import io.tokenpilot.core.domain.*; import io.tokenpilot.core.exception.MissingPricingException; +import io.tokenpilot.core.internal.LedgerComponents; import io.tokenpilot.springai.UsageExtractor; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -268,6 +269,96 @@ void reconcileActualWithPricingSnapshot() { verify(ledgerManager, never()).record(eq("gpt-4o"), same(usage), anyMap()); } + @Test + @DisplayName("actual usage에 필요한 rate가 없으면 UNPRICED로 남기고 비용을 기록하지 않아야 한다") + void leaveActualUsageUnpricedWhenRequiredRateIsMissing() { + UsageExtractor extractor = mock(UsageExtractor.class); + LedgerListener listener = mock(LedgerListener.class); + CostCalculator costCalculator = LedgerComponents.defaultCostCalculator(); + LedgerManager ledgerManager = LedgerComponents.defaultLedgerManager( + mock(PricingRegistry.class), + costCalculator, + List.of(listener) + ); + TokenUsage usage = TokenUsage.from(1_000, 1_000); + PricingPlan plan = new PricingPlan( + "prompt-only-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.01")), + Currency.getInstance("USD") + ); + PricingSnapshot snapshot = PricingSnapshot.from( + plan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + + when(extractor.extract(any())).thenReturn(usage); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor(ledgerManager, extractor); + ChatClientResponse response = response( + "prompt-only-model", + Map.of( + DefaultLedgerAdvisor.PRICING_SNAPSHOT_CONTEXT, snapshot, + DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT, PricingResolution.RESOLVED + ) + ); + + ChatClientResponse unpricedResponse = advisor.after(response, mock(AdvisorChain.class)); + + assertThat(unpricedResponse.context().get(DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT)) + .isEqualTo(PricingResolution.MISSING_RATE); + assertThat(unpricedResponse.context().get(DefaultLedgerAdvisor.PRICING_RECONCILIATION_RESULT_CONTEXT)) + .isEqualTo(PricingReconciliationResult.UNPRICED); + verifyNoInteractions(listener); + } + + @Test + @DisplayName("FAIL_CLOSED에서 actual usage의 rate가 없으면 reconciliation을 실패시켜야 한다") + void failActualReconciliationWhenRequiredRateIsMissingAndPolicyIsFailClosed() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + BudgetStateStore budgetStateStore = mock(BudgetStateStore.class); + TokenUsage usage = TokenUsage.from(1_000, 1_000); + PricingPlan plan = new PricingPlan( + "prompt-only-model", + Map.of(TokenType.PROMPT, new BigDecimal("0.01")), + Currency.getInstance("USD") + ); + PricingSnapshot snapshot = PricingSnapshot.from( + plan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + + when(extractor.extract(any())).thenReturn(usage); + when(ledgerManager.record(same(snapshot), same(usage), anyMap())) + .thenThrow(new MissingPricingException(PricingResolution.MISSING_RATE)); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + null, + budgetStateStore, + mock(CostCalculator.class), + null, + MissingPricingPolicy.FAIL_CLOSED + ); + ChatClientResponse response = response( + "prompt-only-model", + Map.of( + DefaultLedgerAdvisor.PRICING_SNAPSHOT_CONTEXT, snapshot, + DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT, PricingResolution.RESOLVED + ) + ); + + assertThatThrownBy(() -> advisor.after(response, mock(AdvisorChain.class))) + .isInstanceOf(MissingPricingException.class) + .extracting(exception -> ((MissingPricingException) exception).getResolution()) + .isEqualTo(PricingResolution.MISSING_RATE); + + verifyNoInteractions(budgetStateStore); + } + @Test @DisplayName("explicit zero는 FAIL_CLOSED에서도 RESOLVED로 처리하고 0원 cost로 reconcile해야 한다") void explicitZeroResolvesAndReconcilesWithZeroCost() { From f1d1986f40b330c59b4f8e471882516a745d88c2 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Tue, 4 Aug 2026 01:00:17 +0900 Subject: [PATCH 30/32] =?UTF-8?q?fix(pricing):=20ChatClient=20=EA=B0=80?= =?UTF-8?q?=EA=B2=A9=20=EC=82=AC=EC=A0=84=20=ED=95=B4=EC=84=9D=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokenPilotAutoConfiguration.java | 7 +- .../TokenPilotAutoConfigurationTest.java | 49 +++++++++++ .../SampleApplicationChatClientE2ETest.java | 85 +++++++++++++++++-- .../internal/DefaultLedgerAdvisor.java | 18 +++- .../internal/LedgerSpringAiComponents.java | 17 ++++ .../internal/DefaultLedgerAdvisorTest.java | 49 +++++++++++ 6 files changed, 215 insertions(+), 10 deletions(-) diff --git a/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java b/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java index 421b5c1..9503871 100644 --- a/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java +++ b/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java @@ -126,7 +126,12 @@ public LedgerAdvisor ledgerAdvisor( ); } - return LedgerSpringAiComponents.defaultLedgerAdvisor(ledgerManager, usageExtractor); + return LedgerSpringAiComponents.defaultLedgerAdvisor( + ledgerManager, + usageExtractor, + costCalculator, + pricingRegistry + ); } /** diff --git a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java index 36e1bca..e8eb50c 100644 --- a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java +++ b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java @@ -33,6 +33,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; @@ -87,6 +88,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 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() { diff --git a/token-pilot-sample-app/src/test/java/io/tokenpilot/sample/SampleApplicationChatClientE2ETest.java b/token-pilot-sample-app/src/test/java/io/tokenpilot/sample/SampleApplicationChatClientE2ETest.java index 1d8a110..2298e39 100644 --- a/token-pilot-sample-app/src/test/java/io/tokenpilot/sample/SampleApplicationChatClientE2ETest.java +++ b/token-pilot-sample-app/src/test/java/io/tokenpilot/sample/SampleApplicationChatClientE2ETest.java @@ -1,15 +1,26 @@ package io.tokenpilot.sample; +import io.tokenpilot.budget.BudgetDecision; +import io.tokenpilot.budget.BudgetStateStore; +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 org.junit.jupiter.api.Test; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClientBuilderCustomizer; +import org.springframework.ai.chat.client.ChatClientResponse; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.metadata.ChatResponseMetadata; import org.springframework.ai.chat.metadata.DefaultUsage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.web.server.LocalServerPort; @@ -22,6 +33,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.Currency; import java.util.List; import java.util.Map; @@ -37,6 +49,10 @@ "token-pilot.pricing.plans[0].rates.COMPLETION=0.00060", "token-pilot.metrics.enabled=true", "token-pilot.metrics.tag-whitelist[0]=tenant_id", + "token-pilot.budget.enabled=true", + "token-pilot.budget.monthly-limit=10.00", + "token-pilot.budget.currency=USD", + "token-pilot.budget.target-tag-key=tenant_id", "management.endpoints.web.exposure.include=prometheus,health" } ) @@ -47,6 +63,12 @@ class SampleApplicationChatClientE2ETest { @LocalServerPort private int port; + @Autowired + private ChatClient.Builder chatClientBuilder; + + @Autowired + private BudgetStateStore budgetStateStore; + @Test void chatClientAdvisorRecordsTokenPilotMetricsEndToEnd() throws Exception { HttpResponse beans = get("/test/token-pilot/beans"); @@ -71,6 +93,45 @@ void chatClientAdvisorRecordsTokenPilotMetricsEndToEnd() throws Exception { .doesNotContain("user_id=\"chat-sample-user\""); } + @Test + void budgetAdvisorResolvesModelAndPolicyFromRegularChatClientCall() { + ChatClientResponse response = chatClientBuilder.clone() + .build() + .prompt() + .user("Record this fake budget-aware Spring AI call.") + .advisors(advisors -> advisors.param("tenant_id", "budget-chat-tenant")) + .call() + .chatClientResponse(); + + PricingSnapshot snapshot = contextValue(response, PricingSnapshot.class); + PricingResolution resolution = contextValue(response, PricingResolution.class); + PricingReconciliationResult reconciliationResult = contextValue( + response, + PricingReconciliationResult.class + ); + BudgetDecision budgetDecision = contextValue(response, BudgetDecision.class); + Cost accumulatedCost = budgetStateStore.getAccumulatedCost( + budgetDecision.key(), + budgetDecision.limit() + ); + + assertThat(snapshot.modelId()).isEqualTo("fake-chat-model"); + assertThat(snapshot.pricingPolicyId()).isEqualTo(PricingPlan.DEFAULT_PRICING_POLICY_ID); + assertThat(snapshot.currency()).isEqualTo(Currency.getInstance("USD")); + assertThat(resolution).isEqualTo(PricingResolution.RESOLVED); + assertThat(reconciliationResult).isEqualTo(PricingReconciliationResult.RECONCILED); + assertThat(accumulatedCost.value()).isEqualByComparingTo("0.00135"); + assertThat(accumulatedCost.currency()).isEqualTo(Currency.getInstance("USD")); + } + + private T contextValue(ChatClientResponse response, Class type) { + return response.context().values().stream() + .filter(type::isInstance) + .map(type::cast) + .findFirst() + .orElseThrow(); + } + private HttpResponse get(String path) throws IOException, InterruptedException { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("http://localhost:" + port + path)) @@ -84,13 +145,25 @@ static class FakeChatClientConfiguration { @Bean ChatModel fakeChatModel() { - return prompt -> new ChatResponse( - List.of(new Generation(new AssistantMessage("fake chat response"))), - ChatResponseMetadata.builder() + return new ChatModel() { + @Override + public ChatResponse call(Prompt prompt) { + return new ChatResponse( + List.of(new Generation(new AssistantMessage("fake chat response"))), + ChatResponseMetadata.builder() + .model("fake-chat-model") + .usage(new DefaultUsage(1_000, 2_000)) + .build() + ); + } + + @Override + public ChatOptions getOptions() { + return ChatOptions.builder() .model("fake-chat-model") - .usage(new DefaultUsage(1_000, 2_000)) - .build() - ); + .build(); + } + }; } @Bean diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index 89100c6..49b4cc1 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -16,6 +16,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import org.springframework.ai.chat.prompt.ChatOptions; /** * 기본 {@link LedgerAdvisor} 구현체. @@ -257,11 +258,22 @@ private void rejectMissingPricingIfFailClosed(PricingResolution resolution) { } private String extractModelId(ChatClientRequest request) { - Object value = request.context().get(MODEL_ID_CONTEXT); - if (value instanceof String modelId && !modelId.isBlank()) { + Object contextValue = request.context().get(MODEL_ID_CONTEXT); + if (contextValue instanceof String modelId && !modelId.isBlank()) { return modelId; } - return null; + + ChatOptions options = request.prompt().getOptions(); + if (options == null) { + return null; + } + + String modelId = options.getModel(); + if (modelId == null || modelId.isBlank()) { + return null; + } + + return modelId; } private String extractModelId(ChatClientResponse response) { diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/LedgerSpringAiComponents.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/LedgerSpringAiComponents.java index dfc6fa2..d63c462 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/LedgerSpringAiComponents.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/LedgerSpringAiComponents.java @@ -28,6 +28,23 @@ public static LedgerAdvisor defaultLedgerAdvisor( return new DefaultLedgerAdvisor(ledgerManager, usageExtractor); } + public static LedgerAdvisor defaultLedgerAdvisor( + LedgerManager ledgerManager, + UsageExtractor usageExtractor, + CostCalculator costCalculator, + PricingRegistry pricingRegistry + ) { + return new DefaultLedgerAdvisor( + ledgerManager, + usageExtractor, + null, + null, + costCalculator, + pricingRegistry, + MissingPricingPolicy.FAIL_OPEN + ); + } + public static LedgerAdvisor defaultLedgerAdvisor( LedgerManager ledgerManager, UsageExtractor usageExtractor, diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index ed13e59..5e477e3 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -20,6 +20,7 @@ import org.springframework.ai.chat.metadata.ChatResponseMetadata; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; import java.math.BigDecimal; @@ -173,6 +174,54 @@ void createPricingSnapshotBeforeProviderCall() { verifyNoInteractions(ledgerManager); } + @Test + @DisplayName("Prompt options의 model과 기본 pricing policy로 snapshot을 resolve해야 한다") + void resolvePricingSnapshotFromPromptOptions() { + LedgerManager ledgerManager = mock(LedgerManager.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + PricingPlan plan = new PricingPlan( + "gpt-4o", + new BigDecimal("0.01"), + new BigDecimal("0.03"), + Currency.getInstance("USD") + ); + PricingSnapshot snapshot = PricingSnapshot.from( + plan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + + when(pricingRegistry.resolveSnapshot("gpt-4o", PricingPlan.DEFAULT_PRICING_POLICY_ID)) + .thenReturn(Optional.of(snapshot)); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + mock(UsageExtractor.class), + null, + null, + mock(CostCalculator.class), + pricingRegistry + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt( + "test", + ChatOptions.builder().model("gpt-4o").build() + ), + Map.of() + ); + + ChatClientRequest resolvedRequest = advisor.before(request, mock(AdvisorChain.class)); + + assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT)) + .isEqualTo(PricingResolution.RESOLVED); + assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_SNAPSHOT_CONTEXT)) + .isSameAs(snapshot); + verify(pricingRegistry, times(1)) + .resolveSnapshot("gpt-4o", PricingPlan.DEFAULT_PRICING_POLICY_ID); + verifyNoMoreInteractions(pricingRegistry); + verifyNoInteractions(ledgerManager); + } + @Test @DisplayName("AI 호출 전 completion rate가 없는 부분 snapshot은 MISSING_RATE여야 한다") void resolvePartialPricingSnapshotAsMissingRateBeforeProviderCall() { From 065efa1ed5f75a0e713ea92f7d3665c5e1fa20f0 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Tue, 4 Aug 2026 02:17:04 +0900 Subject: [PATCH 31/32] =?UTF-8?q?refactor(core):=20pricing=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EA=B3=BC=20reconciliation=20=EC=A0=95=EC=B1=85?= =?UTF-8?q?=EC=9D=84=20core=20=EA=B3=84=EC=95=BD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokenPilotAutoConfiguration.java | 17 ++- .../TokenPilotAutoConfigurationTest.java | 26 ++++ .../io/tokenpilot/core/PricingEvaluator.java | 33 +++++ .../internal/DefaultPricingEvaluator.java | 59 +++++++++ .../core/internal/LedgerComponents.java | 5 + .../internal/DefaultPricingEvaluatorTest.java | 113 ++++++++++++++++++ .../internal/DefaultLedgerAdvisor.java | 85 +++++++------ .../internal/LedgerSpringAiComponents.java | 44 +++++++ .../internal/DefaultLedgerAdvisorTest.java | 96 +++++++++++++++ 9 files changed, 431 insertions(+), 47 deletions(-) create mode 100644 token-pilot-core/src/main/java/io/tokenpilot/core/PricingEvaluator.java create mode 100644 token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultPricingEvaluator.java create mode 100644 token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultPricingEvaluatorTest.java diff --git a/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java b/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java index 9503871..e3d2735 100644 --- a/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java +++ b/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java @@ -7,6 +7,7 @@ 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; @@ -71,6 +72,15 @@ public CostCalculator costCalculator() { return LedgerComponents.defaultCostCalculator(); } + /** + * Pricing snapshot rate와 actual model 정합성을 평가하는 정책을 등록합니다. + */ + @Bean + @ConditionalOnMissingBean + public PricingEvaluator pricingEvaluator() { + return LedgerComponents.defaultPricingEvaluator(); + } + /** * 비용 기록 및 리스너 관리를 담당하는 LedgerManager를 등록합니다. */ @@ -109,7 +119,8 @@ public LedgerAdvisor ledgerAdvisor( ObjectProvider budgetEvaluator, ObjectProvider budgetStateStore, CostCalculator costCalculator, - PricingRegistry pricingRegistry + PricingRegistry pricingRegistry, + PricingEvaluator pricingEvaluator ) { BudgetEvaluator evaluator = budgetEvaluator.getIfAvailable(); BudgetStateStore stateStore = budgetStateStore.getIfAvailable(); @@ -122,6 +133,7 @@ public LedgerAdvisor ledgerAdvisor( stateStore, costCalculator, pricingRegistry, + pricingEvaluator, MissingPricingPolicy.FAIL_CLOSED ); } @@ -130,7 +142,8 @@ public LedgerAdvisor ledgerAdvisor( ledgerManager, usageExtractor, costCalculator, - pricingRegistry + pricingRegistry, + pricingEvaluator ); } diff --git a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java index e8eb50c..73e9b31 100644 --- a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java +++ b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java @@ -11,10 +11,12 @@ 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; @@ -75,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); @@ -389,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); }); } @@ -460,6 +466,26 @@ 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 snapshot) { + return PricingResolution.MISSING_PLAN; + } + + @Override + public PricingReconciliationResult determineReconciliation( + Optional snapshot, + String actualModelId + ) { + return PricingReconciliationResult.RECONCILIATION_REQUIRED; + } } static class UserCustomPricingRegistry implements PricingRegistry { diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/PricingEvaluator.java b/token-pilot-core/src/main/java/io/tokenpilot/core/PricingEvaluator.java new file mode 100644 index 0000000..16c6976 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/PricingEvaluator.java @@ -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 snapshot); + + /** + * 호출 전 snapshot을 actual 응답 모델에 적용할 수 있는지 판단합니다. + * + * @param snapshot 호출 전에 확정한 pricing snapshot, 확정되지 않은 경우 empty + * @param actualModelId provider가 반환한 actual model id + * @return pricing reconciliation 판단 결과 + */ + PricingReconciliationResult determineReconciliation( + Optional snapshot, + String actualModelId + ); +} diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultPricingEvaluator.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultPricingEvaluator.java new file mode 100644 index 0000000..c75de14 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultPricingEvaluator.java @@ -0,0 +1,59 @@ +package io.tokenpilot.core.internal; + +import io.tokenpilot.core.PricingEvaluator; +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 java.util.Objects; +import java.util.Optional; + +class DefaultPricingEvaluator implements PricingEvaluator { + + @Override + public PricingResolution validateSnapshotRates(Optional snapshot) { + Objects.requireNonNull(snapshot, "snapshot must not be null"); + + if (snapshot.isEmpty()) { + return PricingResolution.MISSING_PLAN; + } + + return resolveRequiredRates(snapshot.get()); + } + + @Override + public PricingReconciliationResult determineReconciliation( + Optional snapshot, + String actualModelId + ) { + Objects.requireNonNull(snapshot, "snapshot must not be null"); + + if (snapshot.isEmpty()) { + return PricingReconciliationResult.UNPRICED; + } + + if (!snapshot.get().modelId().equals(actualModelId)) { + return PricingReconciliationResult.RECONCILIATION_REQUIRED; + } + + return PricingReconciliationResult.RECONCILED; + } + + private PricingResolution resolveRequiredRates(PricingSnapshot snapshot) { + PricingPlan plan = new PricingPlan( + snapshot.modelId(), + snapshot.pricingPolicyId(), + snapshot.rates(), + snapshot.currency() + ); + + PricingResolution promptResolution = plan.resolveRate(TokenType.PROMPT); + if (!promptResolution.isResolved()) { + return promptResolution; + } + + return plan.resolveRate(TokenType.COMPLETION); + } +} 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 533587f..8ced30b 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 @@ -3,6 +3,7 @@ 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; @@ -20,6 +21,10 @@ public static CostCalculator defaultCostCalculator() { return new DefaultCostCalculator(); } + public static PricingEvaluator defaultPricingEvaluator() { + return new DefaultPricingEvaluator(); + } + public static PricingRegistry inMemoryPricingRegistry(List providers) { return new InMemoryPricingRegistry(providers); } diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultPricingEvaluatorTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultPricingEvaluatorTest.java new file mode 100644 index 0000000..a7db95b --- /dev/null +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultPricingEvaluatorTest.java @@ -0,0 +1,113 @@ +package io.tokenpilot.core.internal; + +import io.tokenpilot.core.PricingEvaluator; +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Currency; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultPricingEvaluatorTest { + + private final PricingEvaluator evaluator = LedgerComponents.defaultPricingEvaluator(); + + @Test + @DisplayName("snapshot이 없으면 MISSING_PLAN이어야 한다") + void resolveMissingSnapshotAsMissingPlan() { + PricingResolution resolution = evaluator.validateSnapshotRates(Optional.empty()); + + assertThat(resolution).isEqualTo(PricingResolution.MISSING_PLAN); + } + + @Test + @DisplayName("prompt와 completion rate가 있으면 snapshot rate 검증에 성공해야 한다") + void validateRequiredSnapshotRates() { + PricingSnapshot snapshot = snapshot(Map.of( + TokenType.PROMPT, new BigDecimal("0.01"), + TokenType.COMPLETION, new BigDecimal("0.03") + )); + + PricingResolution resolution = evaluator.validateSnapshotRates(Optional.of(snapshot)); + + assertThat(resolution).isEqualTo(PricingResolution.RESOLVED); + } + + @Test + @DisplayName("completion rate가 없으면 snapshot rate 검증은 MISSING_RATE여야 한다") + void rejectSnapshotWithoutCompletionRate() { + PricingSnapshot snapshot = snapshot(Map.of( + TokenType.PROMPT, new BigDecimal("0.01") + )); + + PricingResolution resolution = evaluator.validateSnapshotRates(Optional.of(snapshot)); + + assertThat(resolution).isEqualTo(PricingResolution.MISSING_RATE); + } + + @Test + @DisplayName("snapshot model과 actual model이 같으면 RECONCILED여야 한다") + void reconcileMatchingActualModel() { + PricingSnapshot snapshot = snapshot(Map.of( + TokenType.PROMPT, new BigDecimal("0.01"), + TokenType.COMPLETION, new BigDecimal("0.03") + )); + + PricingReconciliationResult result = evaluator.determineReconciliation( + Optional.of(snapshot), + "gpt-4o" + ); + + assertThat(result).isEqualTo(PricingReconciliationResult.RECONCILED); + } + + @Test + @DisplayName("snapshot model과 actual model이 다르면 RECONCILIATION_REQUIRED여야 한다") + void requireReconciliationForDifferentActualModel() { + PricingSnapshot snapshot = snapshot(Map.of( + TokenType.PROMPT, new BigDecimal("0.01"), + TokenType.COMPLETION, new BigDecimal("0.03") + )); + + PricingReconciliationResult result = evaluator.determineReconciliation( + Optional.of(snapshot), + "gpt-4o-mini" + ); + + assertThat(result).isEqualTo(PricingReconciliationResult.RECONCILIATION_REQUIRED); + } + + @Test + @DisplayName("snapshot이 없으면 reconciliation 결과는 UNPRICED여야 한다") + void leaveMissingSnapshotUnpriced() { + PricingReconciliationResult result = evaluator.determineReconciliation( + Optional.empty(), + "gpt-4o" + ); + + assertThat(result).isEqualTo(PricingReconciliationResult.UNPRICED); + } + + private static PricingSnapshot snapshot(Map rates) { + PricingPlan plan = new PricingPlan( + "gpt-4o", + "standard", + rates, + Currency.getInstance("USD") + ); + return PricingSnapshot.from( + plan, + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + } +} diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index 49b4cc1..952331c 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -6,6 +6,7 @@ import io.tokenpilot.core.*; import io.tokenpilot.core.domain.*; import io.tokenpilot.core.exception.MissingPricingException; +import io.tokenpilot.core.internal.LedgerComponents; import io.tokenpilot.springai.LedgerAdvisor; import io.tokenpilot.springai.UsageExtractor; import org.springframework.ai.chat.client.ChatClientRequest; @@ -40,6 +41,7 @@ public class DefaultLedgerAdvisor implements LedgerAdvisor { private final BudgetStateStore budgetStateStore; private final CostCalculator costCalculator; private final PricingRegistry pricingRegistry; + private final PricingEvaluator pricingEvaluator; private final MissingPricingPolicy missingPricingPolicy; public DefaultLedgerAdvisor(LedgerManager ledgerManager, UsageExtractor usageExtractor) { @@ -64,12 +66,33 @@ public DefaultLedgerAdvisor(LedgerManager ledgerManager, UsageExtractor usageExt BudgetEvaluator budgetEvaluator, BudgetStateStore budgetStateStore, CostCalculator costCalculator, PricingRegistry pricingRegistry, MissingPricingPolicy missingPricingPolicy) { + this( + ledgerManager, + usageExtractor, + budgetEvaluator, + budgetStateStore, + costCalculator, + pricingRegistry, + LedgerComponents.defaultPricingEvaluator(), + missingPricingPolicy + ); + } + + public DefaultLedgerAdvisor(LedgerManager ledgerManager, UsageExtractor usageExtractor, + BudgetEvaluator budgetEvaluator, BudgetStateStore budgetStateStore, + CostCalculator costCalculator, PricingRegistry pricingRegistry, + PricingEvaluator pricingEvaluator, + MissingPricingPolicy missingPricingPolicy) { this.ledgerManager = ledgerManager; this.usageExtractor = usageExtractor; this.budgetEvaluator = budgetEvaluator; this.budgetStateStore = budgetStateStore; this.costCalculator = costCalculator; this.pricingRegistry = pricingRegistry; + this.pricingEvaluator = Objects.requireNonNull( + pricingEvaluator, + "pricingEvaluator must not be null" + ); this.missingPricingPolicy = Objects.requireNonNull( missingPricingPolicy, "missingPricingPolicy must not be null" @@ -105,20 +128,27 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) Optional snapshot = extractPricingSnapshot(response); boolean hasPricingResolution = hasPricingResolution(response); - if (snapshot.isPresent()) { - if (!snapshot.get().modelId().equals(responseModelId)) { - return withReconciliationResult(response, PricingReconciliationResult.RECONCILIATION_REQUIRED); + if (snapshot.isPresent() || hasPricingResolution) { + PricingReconciliationResult reconciliationResult = pricingEvaluator.determineReconciliation( + snapshot, + responseModelId + ); + if (reconciliationResult != PricingReconciliationResult.RECONCILED) { + return withReconciliationResult(response, reconciliationResult); } + PricingSnapshot resolvedSnapshot = snapshot.orElseThrow( + () -> new IllegalStateException("Reconciled pricing snapshot is missing") + ); Cost cost; try { - cost = ledgerManager.record(snapshot.get(), usage, tags); + cost = ledgerManager.record(resolvedSnapshot, usage, tags); } catch (MissingPricingException exception) { return handleActualPricingFailure(response, exception); } ChatClientResponse reconciledResponse = withReconciliationResult( response, - PricingReconciliationResult.RECONCILED + reconciliationResult ); if (budgetStateStore != null) { BudgetDecision decision = extractBudgetDecision(reconciledResponse); @@ -129,11 +159,9 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) ); } return reconciledResponse; - } else if (!hasPricingResolution) { + } else { ledgerManager.record(modelId, usage, tags); recordLegacyBudgetCost(modelId, usage, response); - } else { - return withReconciliationResult(response, PricingReconciliationResult.UNPRICED); } return response; @@ -189,49 +217,16 @@ private void recordLegacyBudgetCost(String modelId, TokenUsage usage, ChatClient private ChatClientRequest resolvePricing(ChatClientRequest request) { String modelId = extractModelId(request); - if (modelId == null) { - PricingResolution resolution = PricingResolution.MISSING_PLAN; - rejectMissingPricingIfFailClosed(resolution); - return withPricingContext( - request, - extractPricingPolicyId(request), - resolution, - Optional.empty() - ); - } - String pricingPolicyId = extractPricingPolicyId(request); - Optional snapshot = pricingRegistry.resolveSnapshot(modelId, pricingPolicyId); - PricingResolution resolution = resolvePricingResolution(snapshot); + Optional snapshot = modelId == null + ? Optional.empty() + : pricingRegistry.resolveSnapshot(modelId, pricingPolicyId); + PricingResolution resolution = pricingEvaluator.validateSnapshotRates(snapshot); rejectMissingPricingIfFailClosed(resolution); return withPricingContext(request, pricingPolicyId, resolution, snapshot); } - private PricingResolution resolvePricingResolution(Optional snapshot) { - if (snapshot.isEmpty()) { - return PricingResolution.MISSING_PLAN; - } - - return resolveRequiredRates(snapshot.get()); - } - - private PricingResolution resolveRequiredRates(PricingSnapshot snapshot) { - PricingPlan plan = new PricingPlan( - snapshot.modelId(), - snapshot.pricingPolicyId(), - snapshot.rates(), - snapshot.currency() - ); - - PricingResolution promptResolution = plan.resolveRate(TokenType.PROMPT); - if (!promptResolution.isResolved()) { - return promptResolution; - } - - return plan.resolveRate(TokenType.COMPLETION); - } - private ChatClientRequest withPricingContext( ChatClientRequest request, String pricingPolicyId, diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/LedgerSpringAiComponents.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/LedgerSpringAiComponents.java index d63c462..2ee86cd 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/LedgerSpringAiComponents.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/LedgerSpringAiComponents.java @@ -4,8 +4,10 @@ import io.tokenpilot.budget.BudgetStateStore; import io.tokenpilot.core.CostCalculator; import io.tokenpilot.core.LedgerManager; +import io.tokenpilot.core.PricingEvaluator; import io.tokenpilot.core.PricingRegistry; import io.tokenpilot.core.domain.MissingPricingPolicy; +import io.tokenpilot.core.internal.LedgerComponents; import io.tokenpilot.springai.LedgerAdvisor; import io.tokenpilot.springai.UsageExtractor; @@ -45,6 +47,25 @@ public static LedgerAdvisor defaultLedgerAdvisor( ); } + public static LedgerAdvisor defaultLedgerAdvisor( + LedgerManager ledgerManager, + UsageExtractor usageExtractor, + CostCalculator costCalculator, + PricingRegistry pricingRegistry, + PricingEvaluator pricingEvaluator + ) { + return defaultLedgerAdvisor( + ledgerManager, + usageExtractor, + null, + null, + costCalculator, + pricingRegistry, + pricingEvaluator, + MissingPricingPolicy.FAIL_OPEN + ); + } + public static LedgerAdvisor defaultLedgerAdvisor( LedgerManager ledgerManager, UsageExtractor usageExtractor, @@ -72,6 +93,28 @@ public static LedgerAdvisor defaultLedgerAdvisor( CostCalculator costCalculator, PricingRegistry pricingRegistry, MissingPricingPolicy missingPricingPolicy + ) { + return defaultLedgerAdvisor( + ledgerManager, + usageExtractor, + budgetEvaluator, + budgetStateStore, + costCalculator, + pricingRegistry, + LedgerComponents.defaultPricingEvaluator(), + missingPricingPolicy + ); + } + + public static LedgerAdvisor defaultLedgerAdvisor( + LedgerManager ledgerManager, + UsageExtractor usageExtractor, + BudgetEvaluator budgetEvaluator, + BudgetStateStore budgetStateStore, + CostCalculator costCalculator, + PricingRegistry pricingRegistry, + PricingEvaluator pricingEvaluator, + MissingPricingPolicy missingPricingPolicy ) { return new DefaultLedgerAdvisor( ledgerManager, @@ -80,6 +123,7 @@ public static LedgerAdvisor defaultLedgerAdvisor( budgetStateStore, costCalculator, pricingRegistry, + pricingEvaluator, missingPricingPolicy ); } diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index 5e477e3..53e93e1 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -174,6 +174,53 @@ void createPricingSnapshotBeforeProviderCall() { verifyNoInteractions(ledgerManager); } + @Test + @DisplayName("pricing snapshot rate 검증은 Core PricingEvaluator에 위임해야 한다") + void delegateSnapshotRateValidationToPricingEvaluator() { + LedgerManager ledgerManager = mock(LedgerManager.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + PricingEvaluator pricingEvaluator = mock(PricingEvaluator.class); + PricingSnapshot snapshot = PricingSnapshot.from( + new PricingPlan( + "gpt-4o", + new BigDecimal("0.01"), + new BigDecimal("0.03"), + Currency.getInstance("USD") + ), + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + + when(pricingRegistry.resolveSnapshot("gpt-4o", PricingPlan.DEFAULT_PRICING_POLICY_ID)) + .thenReturn(Optional.of(snapshot)); + when(pricingEvaluator.validateSnapshotRates(any())).thenReturn(PricingResolution.MISSING_RATE); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + mock(UsageExtractor.class), + null, + null, + mock(CostCalculator.class), + pricingRegistry, + pricingEvaluator, + MissingPricingPolicy.FAIL_OPEN + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of(DefaultLedgerAdvisor.MODEL_ID_CONTEXT, "gpt-4o") + ); + + ChatClientRequest resolvedRequest = advisor.before(request, mock(AdvisorChain.class)); + + assertThat(resolvedRequest.context().get(DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT)) + .isEqualTo(PricingResolution.MISSING_RATE); + assertThat(resolvedRequest.context()).doesNotContainKey(DefaultLedgerAdvisor.PRICING_SNAPSHOT_CONTEXT); + verify(pricingEvaluator).validateSnapshotRates( + argThat(candidate -> candidate.isPresent() && candidate.get() == snapshot) + ); + verifyNoInteractions(ledgerManager); + } + @Test @DisplayName("Prompt options의 model과 기본 pricing policy로 snapshot을 resolve해야 한다") void resolvePricingSnapshotFromPromptOptions() { @@ -518,6 +565,55 @@ void requireReconciliationWhenResponseModelDiffersFromSnapshotModel() { verifyNoInteractions(ledgerManager); } + @Test + @DisplayName("actual model reconciliation 판단은 Core PricingEvaluator에 위임해야 한다") + void delegateReconciliationDecisionToPricingEvaluator() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + PricingEvaluator pricingEvaluator = mock(PricingEvaluator.class); + PricingSnapshot snapshot = PricingSnapshot.from( + new PricingPlan( + "gpt-4o-mini", + new BigDecimal("0.01"), + new BigDecimal("0.03"), + Currency.getInstance("USD") + ), + PricingSnapshot.DEFAULT_CATALOG_VERSION, + Instant.parse("2026-07-30T00:00:00Z") + ); + + when(extractor.extract(any())).thenReturn(TokenUsage.from(100, 200)); + when(pricingEvaluator.determineReconciliation(Optional.of(snapshot), "gpt-4o")) + .thenReturn(PricingReconciliationResult.RECONCILIATION_REQUIRED); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + null, + null, + mock(CostCalculator.class), + null, + pricingEvaluator, + MissingPricingPolicy.FAIL_OPEN + ); + + ChatClientResponse result = advisor.after( + response( + "gpt-4o", + Map.of( + DefaultLedgerAdvisor.PRICING_SNAPSHOT_CONTEXT, snapshot, + DefaultLedgerAdvisor.PRICING_RESOLUTION_CONTEXT, PricingResolution.RESOLVED + ) + ), + mock(AdvisorChain.class) + ); + + assertThat(result.context().get(DefaultLedgerAdvisor.PRICING_RECONCILIATION_RESULT_CONTEXT)) + .isEqualTo(PricingReconciliationResult.RECONCILIATION_REQUIRED); + verify(pricingEvaluator).determineReconciliation(Optional.of(snapshot), "gpt-4o"); + verifyNoInteractions(ledgerManager); + } + @Test @DisplayName("FAIL_OPEN은 missing pricing이어도 provider 호출을 허용하고 UNPRICED로 남겨야 한다") void failOpenAllowsProviderCallAndMarksMissingPricingAsUnpriced() { From 87880425da9f81959db0aadaffa4120249de0644 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Tue, 4 Aug 2026 02:34:46 +0900 Subject: [PATCH 32/32] =?UTF-8?q?refactor(spring-ai):=20DefaultLedgerAdvis?= =?UTF-8?q?or=20context=20=EB=B0=8F=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/DefaultLedgerAdvisor.java | 88 ++++++++++--------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index 952331c..f64751d 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -12,6 +12,7 @@ import org.springframework.ai.chat.client.ChatClientRequest; import org.springframework.ai.chat.client.ChatClientResponse; import org.springframework.ai.chat.client.advisor.api.AdvisorChain; +import org.springframework.ai.chat.model.ChatResponse; import java.util.HashMap; import java.util.Map; @@ -121,7 +122,7 @@ public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) { @Override public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) { TokenUsage usage = usageExtractor.extract(response); - + String modelId = extractModelId(response); String responseModelId = extractResponseModelId(response); Map tags = extractTags(response); @@ -175,10 +176,7 @@ private ChatClientResponse handleActualPricingFailure( throw exception; } - Map context = new HashMap<>(); - if (response.context() != null) { - context.putAll(response.context()); - } + Map context = copyContext(response); context.put(PRICING_RESOLUTION_CONTEXT, exception.getResolution()); context.put(PRICING_RECONCILIATION_RESULT_CONTEXT, PricingReconciliationResult.UNPRICED); return new ChatClientResponse(response.chatResponse(), context); @@ -188,10 +186,7 @@ private ChatClientResponse withReconciliationResult( ChatClientResponse response, PricingReconciliationResult result ) { - Map context = new HashMap<>(); - if (response.context() != null) { - context.putAll(response.context()); - } + Map context = copyContext(response); context.put(PRICING_RECONCILIATION_RESULT_CONTEXT, result); return new ChatClientResponse(response.chatResponse(), context); } @@ -272,35 +267,42 @@ private String extractModelId(ChatClientRequest request) { } private String extractModelId(ChatClientResponse response) { - Map context = response.context(); - Object value = null; - if (context != null) { - value = context.get(MODEL_ID_CONTEXT); - } - + Object value = contextValue(response, MODEL_ID_CONTEXT); if (value instanceof String modelId && !modelId.isBlank()) { return modelId; } - if (response.chatResponse() != null && response.chatResponse().getMetadata() != null) { - String model = response.chatResponse().getMetadata().getModel(); - if (model != null && !model.isBlank()) { - return model; - } + String metadataModelId = extractMetadataModelId(response); + if (metadataModelId != null) { + return metadataModelId; } return "unknown-model"; } private String extractResponseModelId(ChatClientResponse response) { - if (response.chatResponse() != null && response.chatResponse().getMetadata() != null) { - String model = response.chatResponse().getMetadata().getModel(); - if (model != null && !model.isBlank()) { - return model; - } + String metadataModelId = extractMetadataModelId(response); + if (metadataModelId != null) { + return metadataModelId; } return extractModelId(response); } + private String extractMetadataModelId(ChatClientResponse response) { + ChatResponse chatResponse = response.chatResponse(); + if (chatResponse == null || chatResponse.getMetadata() == null) { + return null; + } + + String modelId = chatResponse.getMetadata() + .getModel(); + + if (modelId == null || modelId.isBlank()) { + return null; + } + + return modelId; + } + private String extractPricingPolicyId(ChatClientRequest request) { Object value = request.context().get(PRICING_POLICY_ID_CONTEXT); if (value instanceof String pricingPolicyId && !pricingPolicyId.isBlank()) { @@ -310,12 +312,7 @@ private String extractPricingPolicyId(ChatClientRequest request) { } private Optional extractPricingSnapshot(ChatClientResponse response) { - Map context = response.context(); - Object value = null; - if (context != null) { - value = context.get(PRICING_SNAPSHOT_CONTEXT); - } - + Object value = contextValue(response, PRICING_SNAPSHOT_CONTEXT); if (value instanceof PricingSnapshot snapshot) { return Optional.of(snapshot); } @@ -323,11 +320,7 @@ private Optional extractPricingSnapshot(ChatClientResponse resp } private boolean hasPricingResolution(ChatClientResponse response) { - Map context = response.context(); - if (context == null) { - return false; - } - return context.get(PRICING_RESOLUTION_CONTEXT) instanceof PricingResolution; + return contextValue(response, PRICING_RESOLUTION_CONTEXT) instanceof PricingResolution; } private Map extractTags(ChatClientResponse response) { @@ -335,12 +328,7 @@ private Map extractTags(ChatClientResponse response) { } private BudgetDecision extractBudgetDecision(ChatClientResponse response) { - Map context = response.context(); - Object value = null; - if (context != null) { - value = context.get(BUDGET_DECISION_CONTEXT); - } - + Object value = contextValue(response, BUDGET_DECISION_CONTEXT); if (value instanceof BudgetDecision decision) { return decision; } @@ -360,4 +348,20 @@ private Map extractTags(Map context) { } return tags; } + + private Object contextValue(ChatClientResponse response, String key) { + Map context = response.context(); + if (context == null) { + return null; + } + return context.get(key); + } + + private Map copyContext(ChatClientResponse response) { + Map context = response.context(); + if (context == null) { + return new HashMap<>(); + } + return new HashMap<>(context); + } }