From b6eda7e2fc24fd5989144acb82af8ffcbdea1951 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sun, 1 Mar 2026 20:57:05 -0600 Subject: [PATCH] AMPR-143 #442 Add PricingService to Ampere SDK I wrote this commit to expose bundled pricing through the public SDK, add consumer pricing overrides, and cover the new API with tests. --- .../files/provider_pricing.v1.json | 1 + .../link/socket/ampere/api/AmpereConfig.kt | 28 +++ .../link/socket/ampere/api/AmpereInstance.kt | 4 + .../socket/ampere/api/PricingOverrides.kt | 109 ++++++++++ .../api/internal/DefaultPricingService.kt | 124 +++++++++++ .../socket/ampere/api/model/ModelPricing.kt | 25 +++ .../ampere/api/model/PricingDataVersion.kt | 15 ++ .../ampere/api/model/PricingEstimate.kt | 28 +++ .../ampere/api/service/PricingService.kt | 60 ++++++ .../api/service/stub/StubAmpereInstance.kt | 3 + .../api/service/stub/StubPricingService.kt | 11 + .../ai/pricing/ProviderPricingCatalog.kt | 1 + .../link/socket/ampere/api/PricingApiTest.kt | 198 ++++++++++++++++++ .../link/socket/ampere/api/Ampere.jvm.kt | 4 + .../api/internal/DefaultAmpereInstance.kt | 5 + .../socket/ampere/api/AmpereInstanceTest.kt | 17 ++ .../ampere/api/ConsumerSimulationTest.kt | 69 ++++++ .../ai/pricing/ProviderPricingCatalogTest.kt | 1 + 18 files changed, 703 insertions(+) create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/api/PricingOverrides.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/api/internal/DefaultPricingService.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/api/model/ModelPricing.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/api/model/PricingDataVersion.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/api/model/PricingEstimate.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/api/service/PricingService.kt create mode 100644 ampere-core/src/commonMain/kotlin/link/socket/ampere/api/service/stub/StubPricingService.kt create mode 100644 ampere-core/src/commonTest/kotlin/link/socket/ampere/api/PricingApiTest.kt diff --git a/ampere-core/src/commonMain/composeResources/files/provider_pricing.v1.json b/ampere-core/src/commonMain/composeResources/files/provider_pricing.v1.json index 4a0cc14c..c2c65831 100644 --- a/ampere-core/src/commonMain/composeResources/files/provider_pricing.v1.json +++ b/ampere-core/src/commonMain/composeResources/files/provider_pricing.v1.json @@ -1,6 +1,7 @@ { "version": 1, "currency": "USD", + "publishedAt": "2026-03-02", "entries": [ { "providerId": "openai", diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/AmpereConfig.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/AmpereConfig.kt index b2ea636e..75fecd91 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/AmpereConfig.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/AmpereConfig.kt @@ -32,12 +32,14 @@ data class AmpereConfig( val workspace: String? = null, val databasePath: String? = null, val onEscalation: ((Escalated) -> Unit)? = null, + val pricingOverrides: PricingOverrides = PricingOverrides(), ) { class Builder { private var providerConfig: ProviderConfig? = null private var workspace: String? = null private var databasePath: String? = null private var escalationHandler: ((Escalated) -> Unit)? = null + private val pricingOverridesBuilder = PricingOverridesBuilder() /** * Set the AI provider configuration. @@ -75,6 +77,31 @@ data class AmpereConfig( escalationHandler = handler } + /** + * Override bundled pricing data or add private model pricing. + * + * ``` + * pricing { + * model("openai", "gpt-4.1") { + * tier( + * inputUsdPerMillionTokens = 1.5, + * outputUsdPerMillionTokens = 6.0, + * ) + * } + * + * model("self-hosted", "mixtral-enterprise") { + * tier( + * inputUsdPerMillionTokens = 0.0, + * outputUsdPerMillionTokens = 0.0, + * ) + * } + * } + * ``` + */ + fun pricing(configure: PricingOverridesBuilder.() -> Unit) { + pricingOverridesBuilder.apply(configure) + } + fun build(): AmpereConfig { val provider = requireNotNull(providerConfig) { "Provider is required. Use provider(AnthropicConfig()) or similar." @@ -84,6 +111,7 @@ data class AmpereConfig( workspace = workspace, databasePath = databasePath, onEscalation = escalationHandler, + pricingOverrides = pricingOverridesBuilder.build(), ) } } diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/AmpereInstance.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/AmpereInstance.kt index 22b223b9..03b48e0f 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/AmpereInstance.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/AmpereInstance.kt @@ -4,6 +4,7 @@ import link.socket.ampere.api.service.AgentService import link.socket.ampere.api.service.EventService import link.socket.ampere.api.service.KnowledgeService import link.socket.ampere.api.service.OutcomeService +import link.socket.ampere.api.service.PricingService import link.socket.ampere.api.service.StatusService import link.socket.ampere.api.service.ThreadService import link.socket.ampere.api.service.TicketService @@ -42,6 +43,9 @@ interface AmpereInstance : AutoCloseable { /** Execution history and outcome tracking */ val outcomes: OutcomeService + /** Bundled model pricing, overrides, and cost estimation */ + val pricing: PricingService + /** Persistent knowledge and memory */ val knowledge: KnowledgeService diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/PricingOverrides.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/PricingOverrides.kt new file mode 100644 index 00000000..32f80c0d --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/PricingOverrides.kt @@ -0,0 +1,109 @@ +package link.socket.ampere.api + +import kotlinx.serialization.Serializable +import link.socket.ampere.api.model.ModelPricing +import link.socket.ampere.api.model.PricingTier + +/** + * Consumer-provided pricing entries that override bundled model rates. + */ +@AmpereStableApi +@Serializable +data class PricingOverrides( + val models: List = emptyList(), +) + +/** + * Builder for [PricingOverrides]. + */ +@AmpereStableApi +class PricingOverridesBuilder { + private val modelsByKey = linkedMapOf() + + /** + * Add or replace pricing for a provider/model pair. + */ + fun model(pricing: ModelPricing) { + validateModelPricing(pricing) + modelsByKey[pricingModelKey(pricing.providerId, pricing.modelId)] = pricing + } + + /** + * Add or replace pricing for a provider/model pair using the DSL. + */ + fun model( + providerId: String, + modelId: String, + configure: ModelPricingBuilder.() -> Unit, + ) { + model(ModelPricingBuilder(providerId = providerId, modelId = modelId).apply(configure).build()) + } + + internal fun build(): PricingOverrides = PricingOverrides(models = modelsByKey.values.toList()) +} + +/** + * Builder for a single [ModelPricing] entry. + */ +@AmpereStableApi +class ModelPricingBuilder internal constructor( + private val providerId: String, + private val modelId: String, +) { + private val tiers = mutableListOf() + + fun tier( + maxInputTokens: Int? = null, + inputUsdPerMillionTokens: Double, + outputUsdPerMillionTokens: Double, + ) { + val tier = PricingTier( + maxInputTokens = maxInputTokens, + inputUsdPerMillionTokens = inputUsdPerMillionTokens, + outputUsdPerMillionTokens = outputUsdPerMillionTokens, + ) + validatePricingTier(tier) + tiers += tier + } + + internal fun build(): ModelPricing { + val pricing = ModelPricing( + providerId = providerId, + modelId = modelId, + tiers = tiers.toList(), + ) + validateModelPricing(pricing) + return pricing + } +} + +internal data class PricingModelKey( + val providerId: String, + val modelId: String, +) + +internal fun pricingModelKey(providerId: String, modelId: String): PricingModelKey = PricingModelKey( + providerId = providerId.trim().lowercase(), + modelId = modelId.trim().lowercase(), +) + +internal fun validateModelPricing(pricing: ModelPricing) { + require(pricing.providerId.isNotBlank()) { "Pricing providerId cannot be blank." } + require(pricing.modelId.isNotBlank()) { "Pricing modelId cannot be blank." } + require(pricing.tiers.isNotEmpty()) { + "Pricing entry ${pricing.providerId}/${pricing.modelId} must include at least one tier." + } + pricing.tiers.forEach(::validatePricingTier) +} + +internal fun validatePricingTier(tier: PricingTier) { + require(tier.maxInputTokens == null || tier.maxInputTokens > 0) { + "Pricing tier maxInputTokens must be positive when provided." + } + require(tier.inputUsdPerMillionTokens >= 0.0) { + "Pricing tier inputUsdPerMillionTokens cannot be negative." + } + require(tier.outputUsdPerMillionTokens >= 0.0) { + "Pricing tier outputUsdPerMillionTokens cannot be negative." + } +} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/internal/DefaultPricingService.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/internal/DefaultPricingService.kt new file mode 100644 index 00000000..2c70b54c --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/internal/DefaultPricingService.kt @@ -0,0 +1,124 @@ +package link.socket.ampere.api.internal + +import link.socket.ampere.api.PricingModelKey +import link.socket.ampere.api.PricingOverrides +import link.socket.ampere.api.model.ModelPricing +import link.socket.ampere.api.model.PricingDataVersion +import link.socket.ampere.api.model.PricingEstimateRequest +import link.socket.ampere.api.model.PricingEstimateResult +import link.socket.ampere.api.model.PricingTier +import link.socket.ampere.api.pricingModelKey +import link.socket.ampere.api.service.PricingService +import link.socket.ampere.api.validateModelPricing +import link.socket.ampere.domain.ai.pricing.BundledProviderPricingCatalog +import link.socket.ampere.domain.ai.pricing.ProviderModelPricing +import link.socket.ampere.domain.ai.pricing.ProviderPricingCalculator +import link.socket.ampere.domain.ai.pricing.ProviderPricingCatalog +import link.socket.ampere.domain.ai.pricing.TokenPricingTier + +internal class DefaultPricingService( + private val overrides: PricingOverrides = PricingOverrides(), + private val bundledCatalogLoader: suspend () -> ProviderPricingCatalog = { BundledProviderPricingCatalog.load() }, +) : PricingService { + private var cachedCatalog: EffectivePricingCatalog? = null + + override suspend fun get(providerId: String, modelId: String): Result = runCatching { + effectiveCatalog().entriesByKey[pricingModelKey(providerId, modelId)] + } + + override suspend fun list(): Result> = runCatching { + effectiveCatalog().entriesByKey.values.toList() + } + + override suspend fun version(): Result = runCatching { + effectiveCatalog().version + } + + override suspend fun estimate(request: PricingEstimateRequest): Result = runCatching { + val catalog = effectiveCatalog() + val pricing = catalog.entriesByKey[ + pricingModelKey(request.providerId, request.modelId), + ] ?: return@runCatching null + val inputTokens = request.usage.inputTokens ?: return@runCatching null + val outputTokens = request.usage.outputTokens ?: return@runCatching null + if (inputTokens < 0 || outputTokens < 0) return@runCatching null + + val appliedTier = pricing.tiers.firstOrNull { tier -> + tier.maxInputTokens == null || inputTokens <= tier.maxInputTokens + } ?: return@runCatching null + + val estimatedCost = ProviderPricingCalculator.estimateUsd( + pricing = pricing.toDomainPricing(), + inputTokens = inputTokens, + outputTokens = outputTokens, + ) ?: return@runCatching null + + PricingEstimateResult( + providerId = pricing.providerId, + modelId = pricing.modelId, + usage = request.usage.copy(estimatedCost = estimatedCost), + pricing = pricing, + appliedTier = appliedTier, + version = catalog.version, + ) + } + + private suspend fun effectiveCatalog(): EffectivePricingCatalog { + cachedCatalog?.let { return it } + + overrides.models.forEach(::validateModelPricing) + val bundledCatalog = bundledCatalogLoader() + return bundledCatalog.toEffectiveCatalog(overrides).also { cachedCatalog = it } + } +} + +private data class EffectivePricingCatalog( + val version: PricingDataVersion, + val entriesByKey: LinkedHashMap, +) + +private fun ProviderPricingCatalog.toEffectiveCatalog(overrides: PricingOverrides): EffectivePricingCatalog { + val entriesByKey = linkedMapOf() + + entries.forEach { pricing -> + val apiPricing = pricing.toApiPricing() + entriesByKey[pricingModelKey(apiPricing.providerId, apiPricing.modelId)] = apiPricing + } + overrides.models.forEach { pricing -> + entriesByKey[pricingModelKey(pricing.providerId, pricing.modelId)] = pricing + } + + return EffectivePricingCatalog( + version = PricingDataVersion( + version = version, + currency = currency, + publishedAt = publishedAt, + overridesApplied = overrides.models.size, + ), + entriesByKey = LinkedHashMap(entriesByKey), + ) +} + +private fun ProviderModelPricing.toApiPricing(): ModelPricing = ModelPricing( + providerId = providerId, + modelId = modelId, + tiers = tiers.map(TokenPricingTier::toApiTier), +) + +private fun TokenPricingTier.toApiTier(): PricingTier = PricingTier( + maxInputTokens = maxInputTokens, + inputUsdPerMillionTokens = inputUsdPerMillionTokens, + outputUsdPerMillionTokens = outputUsdPerMillionTokens, +) + +private fun ModelPricing.toDomainPricing(): ProviderModelPricing = ProviderModelPricing( + providerId = providerId, + modelId = modelId, + tiers = tiers.map(PricingTier::toDomainTier), +) + +private fun PricingTier.toDomainTier(): TokenPricingTier = TokenPricingTier( + maxInputTokens = maxInputTokens, + inputUsdPerMillionTokens = inputUsdPerMillionTokens, + outputUsdPerMillionTokens = outputUsdPerMillionTokens, +) diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/model/ModelPricing.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/model/ModelPricing.kt new file mode 100644 index 00000000..8976265c --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/model/ModelPricing.kt @@ -0,0 +1,25 @@ +package link.socket.ampere.api.model + +import kotlinx.serialization.Serializable + +/** + * Effective token pricing for a provider/model pair. + */ +@link.socket.ampere.api.AmpereStableApi +@Serializable +data class ModelPricing( + val providerId: String, + val modelId: String, + val tiers: List, +) + +/** + * Token price tier expressed in USD per million tokens. + */ +@link.socket.ampere.api.AmpereStableApi +@Serializable +data class PricingTier( + val maxInputTokens: Int? = null, + val inputUsdPerMillionTokens: Double, + val outputUsdPerMillionTokens: Double, +) diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/model/PricingDataVersion.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/model/PricingDataVersion.kt new file mode 100644 index 00000000..bb7f26df --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/model/PricingDataVersion.kt @@ -0,0 +1,15 @@ +package link.socket.ampere.api.model + +import kotlinx.serialization.Serializable + +/** + * Version metadata for bundled pricing data plus any consumer overrides. + */ +@link.socket.ampere.api.AmpereStableApi +@Serializable +data class PricingDataVersion( + val version: Int, + val currency: String, + val publishedAt: String? = null, + val overridesApplied: Int = 0, +) diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/model/PricingEstimate.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/model/PricingEstimate.kt new file mode 100644 index 00000000..0ddfe05f --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/model/PricingEstimate.kt @@ -0,0 +1,28 @@ +package link.socket.ampere.api.model + +import kotlinx.serialization.Serializable + +/** + * Inputs for pricing estimation. + */ +@link.socket.ampere.api.AmpereStableApi +@Serializable +data class PricingEstimateRequest( + val providerId: String, + val modelId: String, + val usage: TokenUsage, +) + +/** + * Estimated cost plus the pricing data used to compute it. + */ +@link.socket.ampere.api.AmpereStableApi +@Serializable +data class PricingEstimateResult( + val providerId: String, + val modelId: String, + val usage: TokenUsage, + val pricing: ModelPricing, + val appliedTier: PricingTier, + val version: PricingDataVersion, +) diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/service/PricingService.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/service/PricingService.kt new file mode 100644 index 00000000..abbb1708 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/service/PricingService.kt @@ -0,0 +1,60 @@ +package link.socket.ampere.api.service + +import link.socket.ampere.api.model.ModelPricing +import link.socket.ampere.api.model.PricingDataVersion +import link.socket.ampere.api.model.PricingEstimateRequest +import link.socket.ampere.api.model.PricingEstimateResult +import link.socket.ampere.api.model.TokenUsage + +/** + * SDK service for bundled model pricing lookup and token cost estimation. + * + * ``` + * val pricing = ampere.pricing.get("openai", "gpt-4.1").getOrNull() + * val estimate = ampere.pricing.estimate( + * "openai", + * "gpt-4.1", + * TokenUsage(inputTokens = 1_000, outputTokens = 500), + * ).getOrNull() + * ``` + */ +@link.socket.ampere.api.AmpereStableApi +interface PricingService { + + /** + * Get pricing for a provider/model pair, or null if none is known. + */ + suspend fun get(providerId: String, modelId: String): Result + + /** + * List all effective pricing entries, including consumer overrides. + */ + suspend fun list(): Result> + + /** + * Get version metadata for the effective pricing data set. + */ + suspend fun version(): Result + + /** + * Estimate token cost using the effective pricing data set. + * + * Returns null when the model is unknown or token counts are unavailable. + */ + suspend fun estimate(request: PricingEstimateRequest): Result + + /** + * Convenience overload for [estimate]. + */ + suspend fun estimate( + providerId: String, + modelId: String, + usage: TokenUsage, + ): Result = estimate( + PricingEstimateRequest( + providerId = providerId, + modelId = modelId, + usage = usage, + ), + ) +} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/service/stub/StubAmpereInstance.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/service/stub/StubAmpereInstance.kt index 4e637804..692e3087 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/service/stub/StubAmpereInstance.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/service/stub/StubAmpereInstance.kt @@ -5,6 +5,7 @@ import link.socket.ampere.api.service.AgentService import link.socket.ampere.api.service.EventService import link.socket.ampere.api.service.KnowledgeService import link.socket.ampere.api.service.OutcomeService +import link.socket.ampere.api.service.PricingService import link.socket.ampere.api.service.StatusService import link.socket.ampere.api.service.ThreadService import link.socket.ampere.api.service.TicketService @@ -33,6 +34,8 @@ class StubAmpereInstance : AmpereInstance { override val outcomes: OutcomeService = StubOutcomeService() + override val pricing: PricingService = StubPricingService() + override val knowledge: KnowledgeService = StubKnowledgeService() override val status: StatusService = StubStatusService() diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/service/stub/StubPricingService.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/service/stub/StubPricingService.kt new file mode 100644 index 00000000..71ea3cad --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/api/service/stub/StubPricingService.kt @@ -0,0 +1,11 @@ +package link.socket.ampere.api.service.stub + +import link.socket.ampere.api.internal.DefaultPricingService +import link.socket.ampere.api.service.PricingService + +/** + * Stub [PricingService] backed by bundled in-memory pricing data. + */ +class StubPricingService( + private val delegate: PricingService = DefaultPricingService(), +) : PricingService by delegate diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.kt index 52a6f665..2103c178 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.kt @@ -9,6 +9,7 @@ import org.jetbrains.compose.resources.ExperimentalResourceApi data class ProviderPricingCatalog( val version: Int, val currency: String, + val publishedAt: String? = null, val entries: List, ) diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/api/PricingApiTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/api/PricingApiTest.kt new file mode 100644 index 00000000..6007c16f --- /dev/null +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/api/PricingApiTest.kt @@ -0,0 +1,198 @@ +package link.socket.ampere.api + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import link.socket.ampere.api.internal.DefaultPricingService +import link.socket.ampere.api.model.ModelPricing +import link.socket.ampere.api.model.PricingDataVersion +import link.socket.ampere.api.model.PricingEstimateResult +import link.socket.ampere.api.model.PricingTier +import link.socket.ampere.api.model.TokenUsage +import link.socket.ampere.data.DEFAULT_JSON +import link.socket.ampere.domain.ai.pricing.ProviderModelPricing +import link.socket.ampere.domain.ai.pricing.ProviderPricingCatalog +import link.socket.ampere.domain.ai.pricing.TokenPricingTier +import link.socket.ampere.dsl.config.AnthropicConfig + +class PricingApiTest { + @Test + fun `pricing models round trip through serialization`() { + val result = PricingEstimateResult( + providerId = "openai", + modelId = "gpt-4.1", + usage = TokenUsage( + inputTokens = 1_000, + outputTokens = 500, + estimatedCost = 0.006, + ), + pricing = ModelPricing( + providerId = "openai", + modelId = "gpt-4.1", + tiers = listOf( + PricingTier( + inputUsdPerMillionTokens = 2.0, + outputUsdPerMillionTokens = 8.0, + ), + ), + ), + appliedTier = PricingTier( + inputUsdPerMillionTokens = 2.0, + outputUsdPerMillionTokens = 8.0, + ), + version = PricingDataVersion( + version = 1, + currency = "USD", + publishedAt = "2026-03-02", + overridesApplied = 1, + ), + ) + + val json = DEFAULT_JSON.encodeToString(result) + val decoded = DEFAULT_JSON.decodeFromString(PricingEstimateResult.serializer(), json) + + assertEquals(result, decoded) + } + + @Test + fun `AmpereConfig pricing DSL accumulates overrides by provider and model`() { + val config = AmpereConfig.Builder().apply { + provider(AnthropicConfig()) + pricing { + model("openai", "gpt-4.1") { + tier( + inputUsdPerMillionTokens = 2.0, + outputUsdPerMillionTokens = 8.0, + ) + } + } + pricing { + model("OPENAI", "GPT-4.1") { + tier( + inputUsdPerMillionTokens = 1.0, + outputUsdPerMillionTokens = 2.0, + ) + } + model("self-hosted", "mixtral-enterprise") { + tier( + inputUsdPerMillionTokens = 0.0, + outputUsdPerMillionTokens = 0.0, + ) + } + } + }.build() + + assertEquals(2, config.pricingOverrides.models.size) + val overridden = config.pricingOverrides.models.first { it.providerId.equals("OPENAI", ignoreCase = true) } + assertEquals(1.0, overridden.tiers.single().inputUsdPerMillionTokens) + assertTrue( + config.pricingOverrides.models.any { it.providerId == "self-hosted" && it.modelId == "mixtral-enterprise" }, + ) + } + + @Test + fun `default pricing service merges overrides and custom models`() = runTest { + val service = DefaultPricingService( + overrides = PricingOverrides( + models = listOf( + ModelPricing( + providerId = "openai", + modelId = "gpt-4.1", + tiers = listOf( + PricingTier( + inputUsdPerMillionTokens = 1.0, + outputUsdPerMillionTokens = 2.0, + ), + ), + ), + ModelPricing( + providerId = "self-hosted", + modelId = "mixtral-enterprise", + tiers = listOf( + PricingTier( + inputUsdPerMillionTokens = 0.0, + outputUsdPerMillionTokens = 0.0, + ), + ), + ), + ), + ), + bundledCatalogLoader = { + ProviderPricingCatalog( + version = 7, + currency = "USD", + publishedAt = "2026-02-28", + entries = listOf( + ProviderModelPricing( + providerId = "openai", + modelId = "gpt-4.1", + tiers = listOf( + TokenPricingTier( + inputUsdPerMillionTokens = 2.0, + outputUsdPerMillionTokens = 8.0, + ), + ), + ), + ), + ) + }, + ) + + val knownPricing = service.get("OPENAI", "GPT-4.1").getOrThrow() + val allPricing = service.list().getOrThrow() + val version = service.version().getOrThrow() + + assertNotNull(knownPricing) + assertEquals(1.0, knownPricing.tiers.single().inputUsdPerMillionTokens) + assertEquals(2, allPricing.size) + assertTrue(allPricing.any { it.providerId == "self-hosted" && it.modelId == "mixtral-enterprise" }) + assertEquals(7, version.version) + assertEquals("2026-02-28", version.publishedAt) + assertEquals(2, version.overridesApplied) + } + + @Test + fun `default pricing service estimates cost from effective pricing`() = runTest { + val service = DefaultPricingService( + overrides = PricingOverrides( + models = listOf( + ModelPricing( + providerId = "openai", + modelId = "gpt-4.1", + tiers = listOf( + PricingTier( + inputUsdPerMillionTokens = 1.0, + outputUsdPerMillionTokens = 2.0, + ), + ), + ), + ), + ), + bundledCatalogLoader = { + ProviderPricingCatalog( + version = 3, + currency = "USD", + publishedAt = "2026-03-01", + entries = emptyList(), + ) + }, + ) + + val estimate = service.estimate( + providerId = "openai", + modelId = "gpt-4.1", + usage = TokenUsage( + inputTokens = 1_000, + outputTokens = 500, + ), + ).getOrThrow() + + assertNotNull(estimate) + assertEquals(0.002, assertNotNull(estimate.usage.estimatedCost), absoluteTolerance = 0.0000001) + assertEquals(1.0, estimate.appliedTier.inputUsdPerMillionTokens) + assertEquals(3, estimate.version.version) + } +} diff --git a/ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/Ampere.jvm.kt b/ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/Ampere.jvm.kt index 6ad1c4fd..a3af1695 100644 --- a/ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/Ampere.jvm.kt +++ b/ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/Ampere.jvm.kt @@ -12,6 +12,7 @@ import link.socket.ampere.api.internal.DefaultAmpereInstance import link.socket.ampere.api.internal.DefaultEventService import link.socket.ampere.api.internal.DefaultKnowledgeService import link.socket.ampere.api.internal.DefaultOutcomeService +import link.socket.ampere.api.internal.DefaultPricingService import link.socket.ampere.api.internal.DefaultStatusService import link.socket.ampere.api.internal.DefaultThreadService import link.socket.ampere.api.internal.DefaultTicketService @@ -74,6 +75,8 @@ fun Ampere.fromEnvironment( outcomeRepository = environmentService.outcomeMemoryRepository, ) + val pricingService = DefaultPricingService() + val knowledgeService = DefaultKnowledgeService( knowledgeRepository = knowledgeRepository, ) @@ -96,6 +99,7 @@ fun Ampere.fromEnvironment( override val threads = threadService override val events = eventService override val outcomes = outcomeService + override val pricing = pricingService override val knowledge = knowledgeService override val status = statusService override fun close() { diff --git a/ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/internal/DefaultAmpereInstance.kt b/ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/internal/DefaultAmpereInstance.kt index 810db46b..31429ad7 100644 --- a/ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/internal/DefaultAmpereInstance.kt +++ b/ampere-core/src/jvmMain/kotlin/link/socket/ampere/api/internal/DefaultAmpereInstance.kt @@ -20,6 +20,7 @@ import link.socket.ampere.api.service.AgentService import link.socket.ampere.api.service.EventService import link.socket.ampere.api.service.KnowledgeService import link.socket.ampere.api.service.OutcomeService +import link.socket.ampere.api.service.PricingService import link.socket.ampere.api.service.StatusService import link.socket.ampere.api.service.ThreadService import link.socket.ampere.api.service.TicketService @@ -103,6 +104,10 @@ internal class DefaultAmpereInstance( outcomeRepository = environmentService.outcomeMemoryRepository, ) + override val pricing: PricingService = DefaultPricingService( + overrides = config.pricingOverrides, + ) + override val knowledge: KnowledgeService = DefaultKnowledgeService( knowledgeRepository = KnowledgeRepositoryImpl(database), ) diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/api/AmpereInstanceTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/api/AmpereInstanceTest.kt index d8cb0507..56b8cd1a 100644 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/api/AmpereInstanceTest.kt +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/api/AmpereInstanceTest.kt @@ -395,6 +395,10 @@ class AmpereInstanceTest { database(":memory:") } assertNotNull(instance) + runBlocking { + assertEquals("USD", instance.pricing.version().getOrThrow().currency) + assertNotNull(instance.pricing.get("openai", "gpt-4.1").getOrThrow()) + } instance.close() } @@ -443,6 +447,19 @@ class AmpereInstanceTest { val stats = ampere.outcomes.stats().getOrNull()!! assertEquals(0, stats.totalOutcomes) + // Pricing: bundled lookups and estimates work + val pricing = ampere.pricing.get("openai", "gpt-4.1").getOrNull() + assertNotNull(pricing) + val estimate = ampere.pricing.estimate( + providerId = "openai", + modelId = "gpt-4.1", + usage = link.socket.ampere.api.model.TokenUsage( + inputTokens = 1_000, + outputTokens = 500, + ), + ).getOrNull() + assertNotNull(estimate) + // Status: snapshot returns zeroed val snapshot = ampere.status.snapshot().getOrNull()!! assertEquals(0, snapshot.activeTickets) diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/api/ConsumerSimulationTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/api/ConsumerSimulationTest.kt index 0a34ef28..0006505d 100644 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/api/ConsumerSimulationTest.kt +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/api/ConsumerSimulationTest.kt @@ -1,6 +1,7 @@ package link.socket.ampere.api import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlinx.coroutines.flow.Flow @@ -34,15 +35,22 @@ import link.socket.ampere.api.model.AgentSnapshot import link.socket.ampere.api.model.AgentState import link.socket.ampere.api.model.HealthLevel import link.socket.ampere.api.model.HealthStatus +import link.socket.ampere.api.model.ModelPricing import link.socket.ampere.api.model.OutcomeStats +import link.socket.ampere.api.model.PricingDataVersion +import link.socket.ampere.api.model.PricingEstimateRequest +import link.socket.ampere.api.model.PricingEstimateResult +import link.socket.ampere.api.model.PricingTier import link.socket.ampere.api.model.SystemSnapshot import link.socket.ampere.api.model.ThreadFilter import link.socket.ampere.api.model.TicketFilter +import link.socket.ampere.api.model.TokenUsage import link.socket.ampere.api.service.AgentService import link.socket.ampere.api.service.EventService import link.socket.ampere.api.service.EventStreamFilter import link.socket.ampere.api.service.KnowledgeService import link.socket.ampere.api.service.OutcomeService +import link.socket.ampere.api.service.PricingService import link.socket.ampere.api.service.StatusService import link.socket.ampere.api.service.ThreadService import link.socket.ampere.api.service.TicketService @@ -244,6 +252,43 @@ class ConsumerSimulationTest { ) } + private val stubPricingService = object : PricingService { + private val version = PricingDataVersion( + version = 1, + currency = "USD", + publishedAt = "2026-03-02", + ) + private val pricing = ModelPricing( + providerId = "openai", + modelId = "gpt-4.1", + tiers = listOf( + PricingTier( + inputUsdPerMillionTokens = 2.0, + outputUsdPerMillionTokens = 8.0, + ), + ), + ) + + override suspend fun get(providerId: String, modelId: String): Result = Result.success( + pricing.takeIf { it.providerId == providerId && it.modelId == modelId }, + ) + + override suspend fun list(): Result> = Result.success(listOf(pricing)) + + override suspend fun version(): Result = Result.success(version) + + override suspend fun estimate(request: PricingEstimateRequest): Result = Result.success( + PricingEstimateResult( + providerId = request.providerId, + modelId = request.modelId, + usage = request.usage.copy(estimatedCost = 0.006), + pricing = pricing, + appliedTier = pricing.tiers.single(), + version = version, + ), + ) + } + // ==================== Consumer Simulation Tests ==================== @Test @@ -435,6 +480,28 @@ class ConsumerSimulationTest { assertNotNull(healthFlow) } + @Test + fun `pricing service - lookup and estimate flow`() = kotlinx.coroutines.runBlocking { + val pricing = stubPricingService.get("openai", "gpt-4.1").getOrThrow() + val allPricing = stubPricingService.list().getOrThrow() + val version = stubPricingService.version().getOrThrow() + val estimate = stubPricingService.estimate( + PricingEstimateRequest( + providerId = "openai", + modelId = "gpt-4.1", + usage = TokenUsage( + inputTokens = 1_000, + outputTokens = 500, + ), + ), + ).getOrThrow() + + assertNotNull(pricing) + assertEquals(1, allPricing.size) + assertEquals("USD", version.currency) + assertEquals(0.006, estimate?.usage?.estimatedCost) + } + @Test fun `AmpereInstance interface provides all service accessors`() { // Verify the AmpereInstance interface shape by creating a stub @@ -444,6 +511,7 @@ class ConsumerSimulationTest { override val threads: ThreadService = stubThreadService override val events: EventService = stubEventService override val outcomes: OutcomeService = stubOutcomeService + override val pricing: PricingService = stubPricingService override val knowledge: KnowledgeService = stubKnowledgeService override val status: StatusService = stubStatusService override fun close() {} @@ -454,6 +522,7 @@ class ConsumerSimulationTest { assertNotNull(instance.threads) assertNotNull(instance.events) assertNotNull(instance.outcomes) + assertNotNull(instance.pricing) assertNotNull(instance.knowledge) assertNotNull(instance.status) } diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalogTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalogTest.kt index 1473853d..3c4d4d56 100644 --- a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalogTest.kt +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalogTest.kt @@ -14,6 +14,7 @@ class ProviderPricingCatalogTest { assertEquals(1, catalog.version) assertEquals("USD", catalog.currency) + assertEquals("2026-03-02", catalog.publishedAt) assertTrue(catalog.entries.isNotEmpty()) }