Skip to content

Commit a7a2c7e

Browse files
authored
AMPR-143 #442 Add PricingService to Ampere SDK (#449)
I wrote this commit to expose bundled pricing through the public SDK, add consumer pricing overrides, and cover the new API with tests.
1 parent 16b462d commit a7a2c7e

18 files changed

Lines changed: 703 additions & 0 deletions

File tree

ampere-core/src/commonMain/composeResources/files/provider_pricing.v1.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"version": 1,
33
"currency": "USD",
4+
"publishedAt": "2026-03-02",
45
"entries": [
56
{
67
"providerId": "openai",

ampere-core/src/commonMain/kotlin/link/socket/ampere/api/AmpereConfig.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,14 @@ data class AmpereConfig(
3232
val workspace: String? = null,
3333
val databasePath: String? = null,
3434
val onEscalation: ((Escalated) -> Unit)? = null,
35+
val pricingOverrides: PricingOverrides = PricingOverrides(),
3536
) {
3637
class Builder {
3738
private var providerConfig: ProviderConfig? = null
3839
private var workspace: String? = null
3940
private var databasePath: String? = null
4041
private var escalationHandler: ((Escalated) -> Unit)? = null
42+
private val pricingOverridesBuilder = PricingOverridesBuilder()
4143

4244
/**
4345
* Set the AI provider configuration.
@@ -75,6 +77,31 @@ data class AmpereConfig(
7577
escalationHandler = handler
7678
}
7779

80+
/**
81+
* Override bundled pricing data or add private model pricing.
82+
*
83+
* ```
84+
* pricing {
85+
* model("openai", "gpt-4.1") {
86+
* tier(
87+
* inputUsdPerMillionTokens = 1.5,
88+
* outputUsdPerMillionTokens = 6.0,
89+
* )
90+
* }
91+
*
92+
* model("self-hosted", "mixtral-enterprise") {
93+
* tier(
94+
* inputUsdPerMillionTokens = 0.0,
95+
* outputUsdPerMillionTokens = 0.0,
96+
* )
97+
* }
98+
* }
99+
* ```
100+
*/
101+
fun pricing(configure: PricingOverridesBuilder.() -> Unit) {
102+
pricingOverridesBuilder.apply(configure)
103+
}
104+
78105
fun build(): AmpereConfig {
79106
val provider = requireNotNull(providerConfig) {
80107
"Provider is required. Use provider(AnthropicConfig()) or similar."
@@ -84,6 +111,7 @@ data class AmpereConfig(
84111
workspace = workspace,
85112
databasePath = databasePath,
86113
onEscalation = escalationHandler,
114+
pricingOverrides = pricingOverridesBuilder.build(),
87115
)
88116
}
89117
}

ampere-core/src/commonMain/kotlin/link/socket/ampere/api/AmpereInstance.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import link.socket.ampere.api.service.AgentService
44
import link.socket.ampere.api.service.EventService
55
import link.socket.ampere.api.service.KnowledgeService
66
import link.socket.ampere.api.service.OutcomeService
7+
import link.socket.ampere.api.service.PricingService
78
import link.socket.ampere.api.service.StatusService
89
import link.socket.ampere.api.service.ThreadService
910
import link.socket.ampere.api.service.TicketService
@@ -42,6 +43,9 @@ interface AmpereInstance : AutoCloseable {
4243
/** Execution history and outcome tracking */
4344
val outcomes: OutcomeService
4445

46+
/** Bundled model pricing, overrides, and cost estimation */
47+
val pricing: PricingService
48+
4549
/** Persistent knowledge and memory */
4650
val knowledge: KnowledgeService
4751

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package link.socket.ampere.api
2+
3+
import kotlinx.serialization.Serializable
4+
import link.socket.ampere.api.model.ModelPricing
5+
import link.socket.ampere.api.model.PricingTier
6+
7+
/**
8+
* Consumer-provided pricing entries that override bundled model rates.
9+
*/
10+
@AmpereStableApi
11+
@Serializable
12+
data class PricingOverrides(
13+
val models: List<ModelPricing> = emptyList(),
14+
)
15+
16+
/**
17+
* Builder for [PricingOverrides].
18+
*/
19+
@AmpereStableApi
20+
class PricingOverridesBuilder {
21+
private val modelsByKey = linkedMapOf<PricingModelKey, ModelPricing>()
22+
23+
/**
24+
* Add or replace pricing for a provider/model pair.
25+
*/
26+
fun model(pricing: ModelPricing) {
27+
validateModelPricing(pricing)
28+
modelsByKey[pricingModelKey(pricing.providerId, pricing.modelId)] = pricing
29+
}
30+
31+
/**
32+
* Add or replace pricing for a provider/model pair using the DSL.
33+
*/
34+
fun model(
35+
providerId: String,
36+
modelId: String,
37+
configure: ModelPricingBuilder.() -> Unit,
38+
) {
39+
model(ModelPricingBuilder(providerId = providerId, modelId = modelId).apply(configure).build())
40+
}
41+
42+
internal fun build(): PricingOverrides = PricingOverrides(models = modelsByKey.values.toList())
43+
}
44+
45+
/**
46+
* Builder for a single [ModelPricing] entry.
47+
*/
48+
@AmpereStableApi
49+
class ModelPricingBuilder internal constructor(
50+
private val providerId: String,
51+
private val modelId: String,
52+
) {
53+
private val tiers = mutableListOf<PricingTier>()
54+
55+
fun tier(
56+
maxInputTokens: Int? = null,
57+
inputUsdPerMillionTokens: Double,
58+
outputUsdPerMillionTokens: Double,
59+
) {
60+
val tier = PricingTier(
61+
maxInputTokens = maxInputTokens,
62+
inputUsdPerMillionTokens = inputUsdPerMillionTokens,
63+
outputUsdPerMillionTokens = outputUsdPerMillionTokens,
64+
)
65+
validatePricingTier(tier)
66+
tiers += tier
67+
}
68+
69+
internal fun build(): ModelPricing {
70+
val pricing = ModelPricing(
71+
providerId = providerId,
72+
modelId = modelId,
73+
tiers = tiers.toList(),
74+
)
75+
validateModelPricing(pricing)
76+
return pricing
77+
}
78+
}
79+
80+
internal data class PricingModelKey(
81+
val providerId: String,
82+
val modelId: String,
83+
)
84+
85+
internal fun pricingModelKey(providerId: String, modelId: String): PricingModelKey = PricingModelKey(
86+
providerId = providerId.trim().lowercase(),
87+
modelId = modelId.trim().lowercase(),
88+
)
89+
90+
internal fun validateModelPricing(pricing: ModelPricing) {
91+
require(pricing.providerId.isNotBlank()) { "Pricing providerId cannot be blank." }
92+
require(pricing.modelId.isNotBlank()) { "Pricing modelId cannot be blank." }
93+
require(pricing.tiers.isNotEmpty()) {
94+
"Pricing entry ${pricing.providerId}/${pricing.modelId} must include at least one tier."
95+
}
96+
pricing.tiers.forEach(::validatePricingTier)
97+
}
98+
99+
internal fun validatePricingTier(tier: PricingTier) {
100+
require(tier.maxInputTokens == null || tier.maxInputTokens > 0) {
101+
"Pricing tier maxInputTokens must be positive when provided."
102+
}
103+
require(tier.inputUsdPerMillionTokens >= 0.0) {
104+
"Pricing tier inputUsdPerMillionTokens cannot be negative."
105+
}
106+
require(tier.outputUsdPerMillionTokens >= 0.0) {
107+
"Pricing tier outputUsdPerMillionTokens cannot be negative."
108+
}
109+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package link.socket.ampere.api.internal
2+
3+
import link.socket.ampere.api.PricingModelKey
4+
import link.socket.ampere.api.PricingOverrides
5+
import link.socket.ampere.api.model.ModelPricing
6+
import link.socket.ampere.api.model.PricingDataVersion
7+
import link.socket.ampere.api.model.PricingEstimateRequest
8+
import link.socket.ampere.api.model.PricingEstimateResult
9+
import link.socket.ampere.api.model.PricingTier
10+
import link.socket.ampere.api.pricingModelKey
11+
import link.socket.ampere.api.service.PricingService
12+
import link.socket.ampere.api.validateModelPricing
13+
import link.socket.ampere.domain.ai.pricing.BundledProviderPricingCatalog
14+
import link.socket.ampere.domain.ai.pricing.ProviderModelPricing
15+
import link.socket.ampere.domain.ai.pricing.ProviderPricingCalculator
16+
import link.socket.ampere.domain.ai.pricing.ProviderPricingCatalog
17+
import link.socket.ampere.domain.ai.pricing.TokenPricingTier
18+
19+
internal class DefaultPricingService(
20+
private val overrides: PricingOverrides = PricingOverrides(),
21+
private val bundledCatalogLoader: suspend () -> ProviderPricingCatalog = { BundledProviderPricingCatalog.load() },
22+
) : PricingService {
23+
private var cachedCatalog: EffectivePricingCatalog? = null
24+
25+
override suspend fun get(providerId: String, modelId: String): Result<ModelPricing?> = runCatching {
26+
effectiveCatalog().entriesByKey[pricingModelKey(providerId, modelId)]
27+
}
28+
29+
override suspend fun list(): Result<List<ModelPricing>> = runCatching {
30+
effectiveCatalog().entriesByKey.values.toList()
31+
}
32+
33+
override suspend fun version(): Result<PricingDataVersion> = runCatching {
34+
effectiveCatalog().version
35+
}
36+
37+
override suspend fun estimate(request: PricingEstimateRequest): Result<PricingEstimateResult?> = runCatching {
38+
val catalog = effectiveCatalog()
39+
val pricing = catalog.entriesByKey[
40+
pricingModelKey(request.providerId, request.modelId),
41+
] ?: return@runCatching null
42+
val inputTokens = request.usage.inputTokens ?: return@runCatching null
43+
val outputTokens = request.usage.outputTokens ?: return@runCatching null
44+
if (inputTokens < 0 || outputTokens < 0) return@runCatching null
45+
46+
val appliedTier = pricing.tiers.firstOrNull { tier ->
47+
tier.maxInputTokens == null || inputTokens <= tier.maxInputTokens
48+
} ?: return@runCatching null
49+
50+
val estimatedCost = ProviderPricingCalculator.estimateUsd(
51+
pricing = pricing.toDomainPricing(),
52+
inputTokens = inputTokens,
53+
outputTokens = outputTokens,
54+
) ?: return@runCatching null
55+
56+
PricingEstimateResult(
57+
providerId = pricing.providerId,
58+
modelId = pricing.modelId,
59+
usage = request.usage.copy(estimatedCost = estimatedCost),
60+
pricing = pricing,
61+
appliedTier = appliedTier,
62+
version = catalog.version,
63+
)
64+
}
65+
66+
private suspend fun effectiveCatalog(): EffectivePricingCatalog {
67+
cachedCatalog?.let { return it }
68+
69+
overrides.models.forEach(::validateModelPricing)
70+
val bundledCatalog = bundledCatalogLoader()
71+
return bundledCatalog.toEffectiveCatalog(overrides).also { cachedCatalog = it }
72+
}
73+
}
74+
75+
private data class EffectivePricingCatalog(
76+
val version: PricingDataVersion,
77+
val entriesByKey: LinkedHashMap<PricingModelKey, ModelPricing>,
78+
)
79+
80+
private fun ProviderPricingCatalog.toEffectiveCatalog(overrides: PricingOverrides): EffectivePricingCatalog {
81+
val entriesByKey = linkedMapOf<PricingModelKey, ModelPricing>()
82+
83+
entries.forEach { pricing ->
84+
val apiPricing = pricing.toApiPricing()
85+
entriesByKey[pricingModelKey(apiPricing.providerId, apiPricing.modelId)] = apiPricing
86+
}
87+
overrides.models.forEach { pricing ->
88+
entriesByKey[pricingModelKey(pricing.providerId, pricing.modelId)] = pricing
89+
}
90+
91+
return EffectivePricingCatalog(
92+
version = PricingDataVersion(
93+
version = version,
94+
currency = currency,
95+
publishedAt = publishedAt,
96+
overridesApplied = overrides.models.size,
97+
),
98+
entriesByKey = LinkedHashMap(entriesByKey),
99+
)
100+
}
101+
102+
private fun ProviderModelPricing.toApiPricing(): ModelPricing = ModelPricing(
103+
providerId = providerId,
104+
modelId = modelId,
105+
tiers = tiers.map(TokenPricingTier::toApiTier),
106+
)
107+
108+
private fun TokenPricingTier.toApiTier(): PricingTier = PricingTier(
109+
maxInputTokens = maxInputTokens,
110+
inputUsdPerMillionTokens = inputUsdPerMillionTokens,
111+
outputUsdPerMillionTokens = outputUsdPerMillionTokens,
112+
)
113+
114+
private fun ModelPricing.toDomainPricing(): ProviderModelPricing = ProviderModelPricing(
115+
providerId = providerId,
116+
modelId = modelId,
117+
tiers = tiers.map(PricingTier::toDomainTier),
118+
)
119+
120+
private fun PricingTier.toDomainTier(): TokenPricingTier = TokenPricingTier(
121+
maxInputTokens = maxInputTokens,
122+
inputUsdPerMillionTokens = inputUsdPerMillionTokens,
123+
outputUsdPerMillionTokens = outputUsdPerMillionTokens,
124+
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package link.socket.ampere.api.model
2+
3+
import kotlinx.serialization.Serializable
4+
5+
/**
6+
* Effective token pricing for a provider/model pair.
7+
*/
8+
@link.socket.ampere.api.AmpereStableApi
9+
@Serializable
10+
data class ModelPricing(
11+
val providerId: String,
12+
val modelId: String,
13+
val tiers: List<PricingTier>,
14+
)
15+
16+
/**
17+
* Token price tier expressed in USD per million tokens.
18+
*/
19+
@link.socket.ampere.api.AmpereStableApi
20+
@Serializable
21+
data class PricingTier(
22+
val maxInputTokens: Int? = null,
23+
val inputUsdPerMillionTokens: Double,
24+
val outputUsdPerMillionTokens: Double,
25+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package link.socket.ampere.api.model
2+
3+
import kotlinx.serialization.Serializable
4+
5+
/**
6+
* Version metadata for bundled pricing data plus any consumer overrides.
7+
*/
8+
@link.socket.ampere.api.AmpereStableApi
9+
@Serializable
10+
data class PricingDataVersion(
11+
val version: Int,
12+
val currency: String,
13+
val publishedAt: String? = null,
14+
val overridesApplied: Int = 0,
15+
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package link.socket.ampere.api.model
2+
3+
import kotlinx.serialization.Serializable
4+
5+
/**
6+
* Inputs for pricing estimation.
7+
*/
8+
@link.socket.ampere.api.AmpereStableApi
9+
@Serializable
10+
data class PricingEstimateRequest(
11+
val providerId: String,
12+
val modelId: String,
13+
val usage: TokenUsage,
14+
)
15+
16+
/**
17+
* Estimated cost plus the pricing data used to compute it.
18+
*/
19+
@link.socket.ampere.api.AmpereStableApi
20+
@Serializable
21+
data class PricingEstimateResult(
22+
val providerId: String,
23+
val modelId: String,
24+
val usage: TokenUsage,
25+
val pricing: ModelPricing,
26+
val appliedTier: PricingTier,
27+
val version: PricingDataVersion,
28+
)

0 commit comments

Comments
 (0)