diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee97ffb..aed58e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Spring AI Ledger CI +name: Token Pilot Compatibility CI on: pull_request: @@ -25,4 +25,4 @@ jobs: run: chmod +x gradlew - name: Build and Test - run: ./gradlew build \ No newline at end of file + run: ./gradlew build diff --git a/AGENTS.md b/AGENTS.md index 84de65e..c63e8e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ Token Pilot의 제품 포지션은 framework-independent Java LLM control and ac - `token-pilot-spring-ai` translates Spring AI requests and `Usage` into Token Pilot core interfaces; it must not own duplicated policy or accounting logic. - `token-pilot-micrometer` remains optional. Core behavior must work without Micrometer. - When Spring AI Observability is present, reuse its standard latency, trace, and input/output/total token telemetry instead of emitting duplicate default token metrics. -- The repository currently compiles against Spring AI 1.1.4 while the differentiation report evaluates 2.0.0. Decide the 0.1.0 support/migration matrix through an ADR and test each supported version before changing metric defaults. +- The 0.1.0 compatibility baseline is Java 25 bytecode/runtime, Spring Boot 4.1.0, and Spring AI 2.0.0. This is the only supported framework combination until another matrix entry is deliberately added and tested. - Token Pilot-owned telemetry focuses on cost, preflight decisions, budget reservations, pricing misses, and estimate/actual reconciliation. - The existing `token-pilot-starter` remains the Spring AI convenience distribution for compatibility. Do not present it as the only product entrypoint. - `token-pilot-core` and the Spring AI starter must be separately consumable artifacts with the same project version. @@ -72,11 +72,11 @@ Token Pilot의 제품 포지션은 framework-independent Java LLM control and ac | Module | Status | Notes | | --- | --- | --- | | `token-pilot-core` | Basic implementation complete | Domain records, pricing, calculator, registry, ledger manager | -| `token-pilot-spring-ai` | Basic implementation complete | `UsageExtractor`, `LedgerAdvisor`, response usage recording | +| `token-pilot-spring-ai` | Basic implementation complete | Spring AI 2.0.0 `UsageExtractor`, `LedgerAdvisor`, response usage recording | | `token-pilot-micrometer` | Basic implementation complete | `MetricsOptions`, tag whitelist, and metric metadata exist; metric ownership must be narrowed | | `token-pilot-budget` | Basic non-atomic implementation | Typed monthly keys and Clock/ZoneId windows implemented; needs BLOCK enforcement, reservation, idempotency, and reconciliation | | `token-pilot-notification` | Basic implementation complete | Event API and deduplication exist; not yet connected to the full advisor/budget lifecycle | -| `token-pilot-autoconfigure` | Basic implementation complete | Bean registration, property binding, pricing/budget/notification wiring, and ChatClient customizer implemented | +| `token-pilot-autoconfigure` | Basic implementation complete | Bean registration, property binding, pricing/budget/notification wiring, and `ChatClientBuilderCustomizer` implemented | | `token-pilot-starter` | Basic implementation complete | Thin final user entrypoint that brings runtime modules together | | `token-pilot-sample-app` | Basic E2E complete | Direct ledger metrics, budget, and fake Spring AI advisor E2E implemented | @@ -87,7 +87,7 @@ The active scope is the 30-day MVP in `docs/30_DAY_MVP_REPORT.md`. Work in this 1. **Correctness baseline** — token total/breakdown invariants, money precision, missing-pricing policy, BLOCK enforcement, budget windows, listener failure policy, and license metadata. 2. **Preflight core** — framework-independent estimator contract, UTF-8 heuristic result with `exact=false`, explicit scope and safe upper bound, versioned model/context metadata, and `TokenBudget.check()`. 3. **Spend control** — atomic reservation state machine, idempotency, concurrency tests, and estimate/actual reconciliation. -4. **Optional integration** — Spring AI version/capability ADR, adapter/autoconfiguration, Token Pilot-owned metrics, plain-Java sample, Spring sample, and external artifact consumption. +4. **Optional integration** — the Java 25/Boot 4.1.0/Spring AI 2.0.0 adapter and autoconfiguration, Token Pilot-owned metrics, plain-Java sample, Spring sample, and external artifact consumption. Do not start exact BPE, routing, fallback, standalone HTTP gateway, durable stores, or multi-tenant administration before the MVP exit criteria pass. @@ -163,7 +163,7 @@ The autoconfigure module provides: - `BudgetEvaluator` - `BudgetStateStore` - `MicroCostMetricsPublisher` - - `ChatClientCustomizer` + - `ChatClientBuilderCustomizer` - `NotificationStateStore` - `BudgetNotificationService` @@ -198,7 +198,7 @@ Default bean graph: | `MicroCostMetricsPublisher` | Micrometer classpath + `token-pilot.metrics.enabled` | Cost/token metrics listener | | `BudgetStateStore` | `token-pilot.budget.enabled` + missing bean | Default in-memory budget state | | `BudgetEvaluator` | `token-pilot.budget.enabled` + missing bean | Default budget evaluator | -| `ChatClientCustomizer` | Spring AI classpath + `LedgerAdvisor` bean | Adds advisor to ChatClient builders | +| `ChatClientBuilderCustomizer` | Spring AI classpath + `LedgerAdvisor` bean | Adds advisor to ChatClient builders | | `NotificationStateStore` | `token-pilot.notification.enabled` + missing bean | Window-based notification deduplication state | | `BudgetNotificationService` | `token-pilot.notification.enabled` + `BudgetNotificationHandler` bean | Publishes budget notification events to user-defined handler | @@ -213,7 +213,7 @@ Autoconfigure tests should use `ApplicationContextRunner` and verify: - budget beans do not register by default - budget beans register when budget is enabled - budget-enabled `LedgerAdvisor` calls `BudgetEvaluator` -- Spring AI classpath registers `UsageExtractor`, `LedgerAdvisor`, and `ChatClientCustomizer` +- Spring AI classpath registers `UsageExtractor`, `LedgerAdvisor`, and `ChatClientBuilderCustomizer` - notification beans do not register by default - notification beans register when notification is enabled and `BudgetNotificationHandler` bean exists @@ -330,7 +330,7 @@ The active checklist is in `docs/30_DAY_MVP_REPORT.md`; detailed long-term works - Spring AI usage extraction converts map/JSON-compatible native usage objects into the normalized core model. Real-provider compatibility fixtures remain required because provider and Spring AI usage shapes can change independently. - Current budget flow is check-then-add, is not an atomic reservation, and may not enforce `BLOCK` before provider invocation. - Current Micrometer `ai.token.*` metrics may duplicate Spring AI Observability; preserve compatibility while deciding default suppression or replacement. -- Spring AI 2.0.0 documentation cannot be assumed to describe the current 1.1.4 runtime exactly; capability detection and supported-version tests are release requirements. +- The verified Spring AI 2.0.0 path is synchronous `ChatClient` usage recording with a fake provider. Streaming cancellation and reconciliation remain outside the current compatibility guarantee. - The repository, README, JReleaser configuration, and every published module POM use the MIT License. `verifyPublicationMetadata` guards this release contract and ensures the sample app is not published. - Sample app E2E uses a fake Spring AI `ChatModel`; real provider API behavior is not yet verified. - `token-pilot-spring-ai-starter` does not exist in the current build; never use it as an install instruction until implemented and published. @@ -344,6 +344,12 @@ Run all tests: ./gradlew test ``` +Verify the fixed Java/Spring compatibility matrix and generated core-only consumer: + +```bash +./gradlew verifyCompatibilityMatrix verifyCoreConsumer +``` + Run sample app after implementation work: ```bash @@ -392,6 +398,9 @@ Stage and deploy a Central release: ### 2026-07-27 +- Fixed the 0.1.0 compatibility baseline to Java 25 bytecode/runtime, Spring Boot 4.1.0, and Spring AI 2.0.0. +- Migrated Spring AI integration to the 2.0 APIs, including `ChatClientBuilderCustomizer` and Jackson 3-compatible native usage conversion. +- Added build-time verification for Java 25 class files, exact Spring dependency resolution, framework-free core publication metadata, and a generated core-only Java 25 consumer. - Removed per-request cost rounding, added explicit external-boundary rounding, and made `Cost` amount/currency invariants explicit. - Migrated budget policy, decision, state-store, notification, Spring AI, autoconfigure, and sample money interfaces to `Cost` without removing the existing `BudgetKey` and Clock-based monthly-window design. - Standardized repository and Maven publication metadata on the MIT License. diff --git a/README.md b/README.md index 7112260..e4df0f3 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,28 @@ policy, and Spring Boot autoconfiguration. The MVP is extending this foundation with preflight control, atomic budget reservation, and actual usage reconciliation. +## 0.1.0 Compatibility Baseline + +The 0.1.0 target supports one explicit runtime combination: + +- Java 25 minimum runtime and Java 25 bytecode +- Spring Boot 4.1.0 +- Spring AI 2.0.0 + +This baseline follows the [Oracle Java support roadmap](https://www.oracle.com/java/technologies/java-se-support-roadmap.html), +the [Spring Boot 4.1 system requirements](https://docs.spring.io/spring-boot/system-requirements.html), +and the [Spring AI 2.0 compatibility guidance](https://docs.spring.io/spring-ai/reference/getting-started.html). + +`token-pilot-core` remains framework-independent and does not publish Spring +Boot, Spring AI, Micrometer, or Reactor dependencies. Spring Boot 3, Spring AI +1.x, other Spring Boot/Spring AI patch combinations, and older Java runtimes +are not part of the 0.1.0 support guarantee. + +The verified Spring AI path is the synchronous `ChatClient` call lifecycle with +a fake provider. Streaming cancellation, preflight blocking, atomic budget +reservation, and estimate/actual reconciliation are still MVP work and are not +included in this compatibility claim. + ## License Token Pilot is licensed under the [MIT License](LICENSE). diff --git a/build.gradle b/build.gradle index 53dd989..bc80e3b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,8 +1,11 @@ +import groovy.json.JsonSlurper import groovy.xml.XmlSlurper +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import java.util.jar.JarFile plugins { id 'java' - id 'org.springframework.boot' version '4.0.5' apply false + id 'org.springframework.boot' version '4.1.0' apply false id 'io.spring.dependency-management' version '1.1.7' apply false id 'org.jreleaser' version '1.24.0' } @@ -17,6 +20,22 @@ def publishedModuleDescriptions = [ 'token-pilot-starter': 'Spring AI convenience starter that assembles Token Pilot runtime modules.' ] +def compatibilityMatrix = [ + java: 25, + springBoot: '4.1.0', + springAi: '2.0.0', + junit: '6.0.3', + assertj: '3.27.7', + mockito: '5.23.0' +] + +def springManagedModules = [ + 'token-pilot-spring-ai', + 'token-pilot-autoconfigure', + 'token-pilot-starter', + 'token-pilot-sample-app' +] as Set + allprojects { group = 'cloud.token-pilot' version = providers.gradleProperty('projectVersion').orElse('0.0.1-SNAPSHOT').get() @@ -51,6 +70,11 @@ jreleaser { description = 'Framework-independent Java LLM usage control and accounting core with optional adapters.' inceptionYear = '2026' license = 'MIT' + languages { + java { + version = compatibilityMatrix.java.toString() + } + } links { homepage = 'https://github.com/tokenpliot/tokenpilot' } @@ -125,25 +149,33 @@ jreleaser { subprojects { apply plugin: 'java-library' - apply plugin: 'io.spring.dependency-management' java { toolchain { - languageVersion = JavaLanguageVersion.of(25) + languageVersion = JavaLanguageVersion.of(compatibilityMatrix.java) } } - dependencyManagement { - imports { - mavenBom "org.springframework.boot:spring-boot-dependencies:4.0.5" - mavenBom "org.springframework.ai:spring-ai-bom:1.1.4" + tasks.withType(JavaCompile).configureEach { + options.release = compatibilityMatrix.java + } + + if (springManagedModules.contains(name)) { + apply plugin: 'io.spring.dependency-management' + + dependencyManagement { + imports { + mavenBom "org.springframework.boot:spring-boot-dependencies:${compatibilityMatrix.springBoot}" + mavenBom "org.springframework.ai:spring-ai-bom:${compatibilityMatrix.springAi}" + } } } dependencies { + testImplementation platform("org.junit:junit-bom:${compatibilityMatrix.junit}") testImplementation 'org.junit.jupiter:junit-jupiter' - testImplementation 'org.assertj:assertj-core' - testImplementation 'org.mockito:mockito-core' + testImplementation "org.assertj:assertj-core:${compatibilityMatrix.assertj}" + testImplementation "org.mockito:mockito-core:${compatibilityMatrix.mockito}" testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } @@ -342,6 +374,280 @@ tasks.register('verifyPublicationMetadata') { } } -tasks.named('check') { +tasks.register('verifyCompatibilityMatrix') { + group = 'verification' + description = 'Verifies the Java 25, Spring Boot 4.1.0, and Spring AI 2.0.0 compatibility baseline.' dependsOn tasks.named('verifyPublicationMetadata') + dependsOn ':token-pilot-core:classes' + dependsOn ':token-pilot-core:generateMetadataFileForMavenJavaPublication' + + doLast { + def expectedJavaVersion = compatibilityMatrix.java as Integer + def expectedClassMajorVersion = expectedJavaVersion + 44 + + subprojects.each { moduleProject -> + moduleProject.tasks.withType(JavaCompile).each { compileTask -> + def configuredRelease = compileTask.options.release.orNull + if (configuredRelease != expectedJavaVersion) { + throw new GradleException( + "${compileTask.path} must compile with --release ${expectedJavaVersion}, but was ${configuredRelease}." + ) + } + } + } + + def coreProject = project(':token-pilot-core') + def corePomFile = coreProject.layout.buildDirectory + .file('publications/mavenJava/pom-default.xml') + .get() + .asFile + def corePom = new XmlSlurper(false, false).parse(corePomFile) + def forbiddenGroups = [ + 'org.springframework', + 'org.springframework.ai', + 'io.micrometer', + 'io.projectreactor' + ] + def forbiddenPomDependencies = corePom.depthFirst().findAll { node -> + node.name() == 'dependency' && forbiddenGroups.any { + node.groupId.text() == it || node.groupId.text().startsWith("${it}.") + } + } + + if (!forbiddenPomDependencies.isEmpty()) { + throw new GradleException( + "token-pilot-core POM must not expose Spring, Spring AI, Micrometer, or Reactor dependencies." + ) + } + + if (corePom.dependencyManagement.size() != 0) { + throw new GradleException('token-pilot-core POM must not import a dependency-management BOM.') + } + + def coreModuleFile = coreProject.layout.buildDirectory + .file('publications/mavenJava/module.json') + .get() + .asFile + def coreModule = new JsonSlurper().parse(coreModuleFile) + def publishedVariants = coreModule.variants.findAll { + it.name == 'apiElements' || it.name == 'runtimeElements' + } + + if (publishedVariants.size() != 2) { + throw new GradleException('token-pilot-core module metadata must publish API and runtime variants.') + } + + publishedVariants.each { variant -> + def publishedJavaVersion = variant.attributes['org.gradle.jvm.version'] + if (publishedJavaVersion != expectedJavaVersion) { + throw new GradleException( + "token-pilot-core ${variant.name} must target Java ${expectedJavaVersion}, but was ${publishedJavaVersion}." + ) + } + + def forbiddenModuleDependencies = (variant.dependencies ?: []).findAll { dependency -> + forbiddenGroups.any { + dependency.group == it || dependency.group?.startsWith("${it}.") + } + } + def forbiddenModuleConstraints = (variant.dependencyConstraints ?: []).findAll { dependency -> + forbiddenGroups.any { + dependency.group == it || dependency.group?.startsWith("${it}.") + } + } + + if (!forbiddenModuleDependencies.isEmpty() || !forbiddenModuleConstraints.isEmpty()) { + throw new GradleException( + "token-pilot-core ${variant.name} must not expose Spring, Spring AI, Micrometer, or Reactor metadata." + ) + } + } + + def coreClassFiles = coreProject.fileTree( + coreProject.layout.buildDirectory.dir('classes/java/main') + ) { + include '**/*.class' + }.files + + if (coreClassFiles.isEmpty()) { + throw new GradleException('No token-pilot-core class files were found for bytecode verification.') + } + + coreClassFiles.each { classFile -> + classFile.withDataInputStream { stream -> + stream.skipBytes(6) + def majorVersion = stream.readUnsignedShort() + if (majorVersion != expectedClassMajorVersion) { + throw new GradleException( + "${classFile.name} must use Java ${expectedJavaVersion} class version " + + "${expectedClassMajorVersion}, but was ${majorVersion}." + ) + } + } + } + + def resolvedVersions = [:] + project(':token-pilot-sample-app') + .configurations + .runtimeClasspath + .incoming + .resolutionResult + .allComponents + .each { component -> + if (component.id instanceof ModuleComponentIdentifier) { + resolvedVersions["${component.id.group}:${component.id.module}"] = component.id.version + } + } + + def requiredVersions = [ + 'org.springframework.boot:spring-boot': compatibilityMatrix.springBoot, + 'org.springframework.ai:spring-ai-model': compatibilityMatrix.springAi, + 'org.springframework.ai:spring-ai-client-chat': compatibilityMatrix.springAi + ] + + requiredVersions.each { coordinate, expectedVersion -> + def actualVersion = resolvedVersions[coordinate] + if (actualVersion != expectedVersion) { + throw new GradleException( + "${coordinate} must resolve to ${expectedVersion}, but was ${actualVersion}." + ) + } + } + + def unsupportedSpringAiVersions = resolvedVersions.findAll { coordinate, version -> + coordinate.startsWith('org.springframework.ai:') && version != compatibilityMatrix.springAi + } + if (!unsupportedSpringAiVersions.isEmpty()) { + throw new GradleException( + "All Spring AI modules must resolve to ${compatibilityMatrix.springAi}: ${unsupportedSpringAiVersions}" + ) + } + + def unsupportedSpringBootVersions = resolvedVersions.findAll { coordinate, version -> + coordinate.startsWith('org.springframework.boot:') && version != compatibilityMatrix.springBoot + } + if (!unsupportedSpringBootVersions.isEmpty()) { + throw new GradleException( + "All Spring Boot modules must resolve to ${compatibilityMatrix.springBoot}: ${unsupportedSpringBootVersions}" + ) + } + + def springAiClassOwners = [:].withDefault { [] } + project(':token-pilot-sample-app') + .configurations + .runtimeClasspath + .incoming + .artifacts + .artifacts + .findAll { + it.id.componentIdentifier instanceof ModuleComponentIdentifier && + it.file.name.endsWith('.jar') + } + .each { artifact -> + new JarFile(artifact.file).withCloseable { jar -> + jar.entries().findAll { entry -> + !entry.directory && + entry.name.startsWith('org/springframework/ai/') && + entry.name.endsWith('.class') && + !entry.name.endsWith('/module-info.class') && + !entry.name.endsWith('/package-info.class') + }.each { entry -> + springAiClassOwners[entry.name] << artifact.file.name + } + } + } + + def duplicateSpringAiClasses = springAiClassOwners.findAll { className, owners -> + owners.size() > 1 + } + if (!duplicateSpringAiClasses.isEmpty()) { + throw new GradleException( + "Duplicate Spring AI classes were found on the starter runtime classpath: ${duplicateSpringAiClasses}" + ) + } + } +} + +def coreConsumerDirectory = layout.buildDirectory.dir('compatibility/core-consumer') + +tasks.register('prepareCoreConsumer') { + group = 'verification' + description = 'Generates a repository-external-style Java 25 consumer for the published core artifact.' + dependsOn ':token-pilot-core:publishMavenJavaPublicationToStagingRepository' + outputs.dir(coreConsumerDirectory) + + doLast { + def consumerDirectory = coreConsumerDirectory.get().asFile + delete(consumerDirectory) + + def sourceDirectory = new File(consumerDirectory, 'src/main/java/compatibility') + sourceDirectory.mkdirs() + + new File(consumerDirectory, 'settings.gradle').text = """ +rootProject.name = 'token-pilot-core-consumer' +""".stripIndent().trim() + System.lineSeparator() + + new File(consumerDirectory, 'build.gradle').text = """ +plugins { + id 'application' +} + +repositories { + maven { url = uri('${project(':token-pilot-core').layout.buildDirectory.dir('staging-deploy').get().asFile.toURI()}') } + mavenCentral() +} + +dependencies { + implementation 'cloud.token-pilot:token-pilot-core:${project.version}' +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(${compatibilityMatrix.java}) + } +} + +tasks.withType(JavaCompile).configureEach { + options.release = ${compatibilityMatrix.java} +} + +application { + mainClass = 'compatibility.CoreConsumer' +} +""".stripIndent().trim() + System.lineSeparator() + + new File(sourceDirectory, 'CoreConsumer.java').text = """ +package compatibility; + +import io.tokenpilot.core.domain.Cost; +import java.math.BigDecimal; +import java.util.Currency; + +public final class CoreConsumer { + private CoreConsumer() { + } + + public static void main(String[] args) { + Cost cost = Cost.of(new BigDecimal("1.25"), Currency.getInstance("USD")); + if (!cost.equals(Cost.zero(Currency.getInstance("USD")).add(cost))) { + throw new IllegalStateException("Token Pilot core consumer verification failed"); + } + System.out.println("token-pilot-core Java 25 consumer OK"); + } +} +""".stripIndent().trim() + System.lineSeparator() + } +} + +tasks.register('verifyCoreConsumer', Exec) { + group = 'verification' + description = 'Compiles and runs the generated Java 25 core-only consumer.' + dependsOn tasks.named('prepareCoreConsumer') + workingDir coreConsumerDirectory + commandLine rootProject.file('gradlew').absolutePath, '--no-daemon', 'run' +} + +tasks.named('check') { + dependsOn tasks.named('verifyCompatibilityMatrix') + dependsOn tasks.named('verifyCoreConsumer') } diff --git a/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/LedgerChatClientCustomizer.java b/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/LedgerChatClientBuilderCustomizer.java similarity index 66% rename from token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/LedgerChatClientCustomizer.java rename to token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/LedgerChatClientBuilderCustomizer.java index 6b82175..5b17ffa 100644 --- a/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/LedgerChatClientCustomizer.java +++ b/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/LedgerChatClientBuilderCustomizer.java @@ -2,16 +2,16 @@ import io.tokenpilot.springai.LedgerAdvisor; import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.client.ChatClientCustomizer; +import org.springframework.ai.chat.client.ChatClientBuilderCustomizer; /** * ChatClient.Builder에 LedgerAdvisor를 자동으로 주입하는 커스터마이저. */ -public class LedgerChatClientCustomizer implements ChatClientCustomizer { +public class LedgerChatClientBuilderCustomizer implements ChatClientBuilderCustomizer { private final LedgerAdvisor ledgerAdvisor; - public LedgerChatClientCustomizer(LedgerAdvisor ledgerAdvisor) { + public LedgerChatClientBuilderCustomizer(LedgerAdvisor ledgerAdvisor) { this.ledgerAdvisor = ledgerAdvisor; } 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 620c2eb..ac1ac6a 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 @@ -133,8 +133,8 @@ public LedgerAdvisor ledgerAdvisor( @Bean @ConditionalOnBean(LedgerAdvisor.class) @ConditionalOnClass(ChatClient.class) - public LedgerChatClientCustomizer ledgerChatClientCustomizer(LedgerAdvisor ledgerAdvisor) { - return new LedgerChatClientCustomizer(ledgerAdvisor); + public LedgerChatClientBuilderCustomizer ledgerChatClientBuilderCustomizer(LedgerAdvisor ledgerAdvisor) { + return new LedgerChatClientBuilderCustomizer(ledgerAdvisor); } /** diff --git a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/ChatClientCustomizerTest.java b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/ChatClientBuilderCustomizerTest.java similarity index 78% rename from token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/ChatClientCustomizerTest.java rename to token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/ChatClientBuilderCustomizerTest.java index 5d7f7d5..8d39441 100644 --- a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/ChatClientCustomizerTest.java +++ b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/ChatClientBuilderCustomizerTest.java @@ -3,7 +3,7 @@ import io.tokenpilot.springai.LedgerAdvisor; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.ai.chat.client.ChatClientCustomizer; +import org.springframework.ai.chat.client.ChatClientBuilderCustomizer; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; @@ -11,21 +11,21 @@ import static org.assertj.core.api.Assertions.assertThat; -class ChatClientCustomizerTest { +class ChatClientBuilderCustomizerTest { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(TokenPilotAutoConfiguration.class)); @Test @DisplayName("Advisor가 존재할 때 ChatClementCustomizer를 등록해야 합니다.") - void shouldRegisterChatClientCustomizerWhenAdvisorExists() { + void shouldRegisterChatClientBuilderCustomizerWhenAdvisorExists() { this.contextRunner .withUserConfiguration(TestAdvisorConfiguration.class) .run(context -> { - assertThat(context).hasSingleBean(ChatClientCustomizer.class); + assertThat(context).hasSingleBean(ChatClientBuilderCustomizer.class); - ChatClientCustomizer customizer = context.getBean(ChatClientCustomizer.class); - assertThat(customizer).isInstanceOf(LedgerChatClientCustomizer.class); + ChatClientBuilderCustomizer customizer = context.getBean(ChatClientBuilderCustomizer.class); + assertThat(customizer).isInstanceOf(LedgerChatClientBuilderCustomizer.class); }); } 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 ea0c5ed..b689fa9 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 @@ -73,7 +73,7 @@ void shouldRegisterDefaultBeans() { assertThat(context).hasSingleBean(UsageExtractor.class); assertThat(context).hasSingleBean(LedgerAdvisor.class); - assertThat(context).hasSingleBean(LedgerChatClientCustomizer.class); + assertThat(context).hasSingleBean(LedgerChatClientBuilderCustomizer.class); assertThat(context).doesNotHaveBean(BudgetStateStore.class); assertThat(context).doesNotHaveBean(BudgetEvaluator.class); diff --git a/token-pilot-micrometer/build.gradle b/token-pilot-micrometer/build.gradle index 09da26f..4f836b2 100644 --- a/token-pilot-micrometer/build.gradle +++ b/token-pilot-micrometer/build.gradle @@ -1,5 +1,5 @@ dependencies { api project(':token-pilot-core') - implementation 'io.micrometer:micrometer-observation' - implementation 'io.micrometer:micrometer-core' + implementation 'io.micrometer:micrometer-observation:1.17.0' + implementation 'io.micrometer:micrometer-core:1.17.0' } 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 9e6d819..1d8a110 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 @@ -2,7 +2,7 @@ import org.junit.jupiter.api.Test; import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.client.ChatClientCustomizer; +import org.springframework.ai.chat.client.ChatClientBuilderCustomizer; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.metadata.ChatResponseMetadata; import org.springframework.ai.chat.metadata.DefaultUsage; @@ -96,7 +96,7 @@ ChatModel fakeChatModel() { @Bean ChatClient.Builder chatClientBuilder( ChatModel chatModel, - ObjectProvider customizers + ObjectProvider customizers ) { ChatClient.Builder builder = ChatClient.builder(chatModel); customizers.orderedStream() diff --git a/token-pilot-spring-ai/build.gradle b/token-pilot-spring-ai/build.gradle index cbe5731..90edee4 100644 --- a/token-pilot-spring-ai/build.gradle +++ b/token-pilot-spring-ai/build.gradle @@ -2,7 +2,6 @@ dependencies { api project(':token-pilot-core') implementation project(':token-pilot-budget') - // 1.1.x 버전부터 spring-ai-core가 분리됨 implementation 'org.springframework.ai:spring-ai-model' implementation 'org.springframework.ai:spring-ai-client-chat' diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultUsageExtractor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultUsageExtractor.java index 4bf9681..f3d1c3b 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultUsageExtractor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultUsageExtractor.java @@ -9,7 +9,8 @@ import org.springframework.ai.chat.metadata.EmptyUsage; import org.springframework.ai.chat.metadata.Usage; import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.util.JacksonUtils; +import tools.jackson.core.type.TypeReference; import java.util.HashMap; import java.util.Map; @@ -21,6 +22,7 @@ * Provider가 비포괄 총량을 반환하면 Token Pilot의 포괄 총량 계약에 맞게 정규화합니다. */ public class DefaultUsageExtractor implements UsageExtractor { + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; @Override public TokenUsage extract(ChatClientResponse response) { @@ -150,7 +152,7 @@ private Map usageFields(Object nativeUsage) { return Map.of(); } try { - return ModelOptionsUtils.objectToMap(nativeUsage); + return JacksonUtils.getDefaultJsonMapper().convertValue(nativeUsage, MAP_TYPE); } catch (RuntimeException ignored) { return Map.of();