diff --git a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/renderer/EventRendererTest.kt b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/renderer/EventRendererTest.kt index d858f257..facd9ab7 100644 --- a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/renderer/EventRendererTest.kt +++ b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/renderer/EventRendererTest.kt @@ -306,6 +306,26 @@ class EventRendererTest { assertContains(rendered, "ProviderCallCompleted") } + @Test + fun `render completed provider call omits cost when unavailable`() { + val output = captureTerminalOutput { _, renderer -> + renderer.render( + providerCallCompletedEvent( + usage = TokenUsage( + inputTokens = 1247, + outputTokens = 892, + estimatedCost = null, + ), + ), + ) + } + + val rendered = stripAnsi(output) + assertContains(rendered, "1,247 in / 892 out") + assertContains(rendered, "340ms") + assertTrue(!rendered.contains("~$")) + } + @Test fun `render shows event source for agent events`() { val output = captureTerminalOutput { _, renderer -> diff --git a/ampere-core/build.gradle.kts b/ampere-core/build.gradle.kts index 680eaede..9ef335a6 100644 --- a/ampere-core/build.gradle.kts +++ b/ampere-core/build.gradle.kts @@ -20,6 +20,10 @@ plugins { id("org.jlleitschuh.gradle.ktlint") } +compose.resources { + packageOfResClass = "link.socket.ampere.resources" +} + val ampereVersion: String by project group = "link.socket" diff --git a/ampere-core/src/androidMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.android.kt b/ampere-core/src/androidMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.android.kt new file mode 100644 index 00000000..24ffbbf4 --- /dev/null +++ b/ampere-core/src/androidMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.android.kt @@ -0,0 +1,30 @@ +package link.socket.ampere.domain.ai.pricing + +internal actual suspend fun loadBundledProviderPricingFallback( + resourcePath: String, + fallbackPath: String, +): String? { + val candidatePaths = listOf( + resourcePath, + fallbackPath, + resourcePath.removePrefix("/"), + fallbackPath.removePrefix("/"), + resourcePath.substringAfterLast('/'), + fallbackPath.substringAfterLast('/'), + ).distinct() + + val classLoaders = listOfNotNull( + Thread.currentThread().contextClassLoader, + BundledProviderPricingCatalog::class.java.classLoader, + ) + + for (classLoader in classLoaders) { + for (candidate in candidatePaths) { + classLoader.getResourceAsStream(candidate)?.use { stream -> + return stream.readBytes().decodeToString() + } + } + } + + return null +} diff --git a/ampere-core/src/commonMain/composeResources/files/provider_pricing.v1.json b/ampere-core/src/commonMain/composeResources/files/provider_pricing.v1.json new file mode 100644 index 00000000..4a0cc14c --- /dev/null +++ b/ampere-core/src/commonMain/composeResources/files/provider_pricing.v1.json @@ -0,0 +1,306 @@ +{ + "version": 1, + "currency": "USD", + "entries": [ + { + "providerId": "openai", + "modelId": "gpt-5", + "tiers": [ + { + "inputUsdPerMillionTokens": 1.25, + "outputUsdPerMillionTokens": 10.0 + } + ] + }, + { + "providerId": "openai", + "modelId": "gpt-5-mini", + "tiers": [ + { + "inputUsdPerMillionTokens": 0.25, + "outputUsdPerMillionTokens": 2.0 + } + ] + }, + { + "providerId": "openai", + "modelId": "gpt-5-nano", + "tiers": [ + { + "inputUsdPerMillionTokens": 0.05, + "outputUsdPerMillionTokens": 0.4 + } + ] + }, + { + "providerId": "openai", + "modelId": "gpt-5.1", + "tiers": [ + { + "inputUsdPerMillionTokens": 2.0, + "outputUsdPerMillionTokens": 8.0 + } + ] + }, + { + "providerId": "openai", + "modelId": "gpt-5.1-chat-latest", + "tiers": [ + { + "inputUsdPerMillionTokens": 2.0, + "outputUsdPerMillionTokens": 8.0 + } + ] + }, + { + "providerId": "openai", + "modelId": "gpt-5.1-codex-max", + "tiers": [ + { + "inputUsdPerMillionTokens": 2.0, + "outputUsdPerMillionTokens": 8.0 + } + ] + }, + { + "providerId": "openai", + "modelId": "gpt-4.1", + "tiers": [ + { + "inputUsdPerMillionTokens": 2.0, + "outputUsdPerMillionTokens": 8.0 + } + ] + }, + { + "providerId": "openai", + "modelId": "gpt-4.1-mini", + "tiers": [ + { + "inputUsdPerMillionTokens": 0.4, + "outputUsdPerMillionTokens": 1.6 + } + ] + }, + { + "providerId": "openai", + "modelId": "gpt-4o", + "tiers": [ + { + "inputUsdPerMillionTokens": 2.5, + "outputUsdPerMillionTokens": 10.0 + } + ] + }, + { + "providerId": "openai", + "modelId": "gpt-4o-mini", + "tiers": [ + { + "inputUsdPerMillionTokens": 0.15, + "outputUsdPerMillionTokens": 0.6 + } + ] + }, + { + "providerId": "openai", + "modelId": "o4-mini", + "tiers": [ + { + "inputUsdPerMillionTokens": 1.1, + "outputUsdPerMillionTokens": 4.4 + } + ] + }, + { + "providerId": "openai", + "modelId": "o3", + "tiers": [ + { + "inputUsdPerMillionTokens": 2.0, + "outputUsdPerMillionTokens": 8.0 + } + ] + }, + { + "providerId": "openai", + "modelId": "o3-mini", + "tiers": [ + { + "inputUsdPerMillionTokens": 1.1, + "outputUsdPerMillionTokens": 4.4 + } + ] + }, + { + "providerId": "anthropic", + "modelId": "claude-opus-4-1", + "tiers": [ + { + "inputUsdPerMillionTokens": 15.0, + "outputUsdPerMillionTokens": 75.0 + } + ] + }, + { + "providerId": "anthropic", + "modelId": "claude-opus-4-0", + "tiers": [ + { + "inputUsdPerMillionTokens": 15.0, + "outputUsdPerMillionTokens": 75.0 + } + ] + }, + { + "providerId": "anthropic", + "modelId": "claude-opus-4-5-20251101", + "tiers": [ + { + "inputUsdPerMillionTokens": 5.0, + "outputUsdPerMillionTokens": 25.0 + } + ] + }, + { + "providerId": "anthropic", + "modelId": "claude-sonnet-4-0", + "tiers": [ + { + "inputUsdPerMillionTokens": 3.0, + "outputUsdPerMillionTokens": 15.0 + } + ] + }, + { + "providerId": "anthropic", + "modelId": "claude-sonnet-4-5-20250929", + "tiers": [ + { + "inputUsdPerMillionTokens": 3.0, + "outputUsdPerMillionTokens": 15.0 + } + ] + }, + { + "providerId": "anthropic", + "modelId": "claude-3-7-sonnet-latest", + "tiers": [ + { + "inputUsdPerMillionTokens": 3.0, + "outputUsdPerMillionTokens": 15.0 + } + ] + }, + { + "providerId": "anthropic", + "modelId": "claude-3-5-haiku-latest", + "tiers": [ + { + "inputUsdPerMillionTokens": 0.8, + "outputUsdPerMillionTokens": 4.0 + } + ] + }, + { + "providerId": "anthropic", + "modelId": "claude-3-haiku-20240307", + "tiers": [ + { + "inputUsdPerMillionTokens": 0.25, + "outputUsdPerMillionTokens": 1.25 + } + ] + }, + { + "providerId": "anthropic", + "modelId": "claude-haiku-4-5", + "tiers": [ + { + "inputUsdPerMillionTokens": 1.0, + "outputUsdPerMillionTokens": 5.0 + } + ] + }, + { + "providerId": "google", + "modelId": "gemini-2.5-pro", + "tiers": [ + { + "maxInputTokens": 200000, + "inputUsdPerMillionTokens": 1.25, + "outputUsdPerMillionTokens": 10.0 + }, + { + "inputUsdPerMillionTokens": 2.5, + "outputUsdPerMillionTokens": 15.0 + } + ] + }, + { + "providerId": "google", + "modelId": "gemini-2.5-flash", + "tiers": [ + { + "maxInputTokens": 200000, + "inputUsdPerMillionTokens": 0.3, + "outputUsdPerMillionTokens": 2.5 + }, + { + "inputUsdPerMillionTokens": 0.6, + "outputUsdPerMillionTokens": 3.5 + } + ] + }, + { + "providerId": "google", + "modelId": "gemini-2.5-flash-lite", + "tiers": [ + { + "maxInputTokens": 200000, + "inputUsdPerMillionTokens": 0.1, + "outputUsdPerMillionTokens": 0.4 + }, + { + "inputUsdPerMillionTokens": 0.2, + "outputUsdPerMillionTokens": 0.8 + } + ] + }, + { + "providerId": "google", + "modelId": "gemini-2.0-flash", + "tiers": [ + { + "inputUsdPerMillionTokens": 0.1, + "outputUsdPerMillionTokens": 0.4 + } + ] + }, + { + "providerId": "google", + "modelId": "gemini-2.0-flash-lite", + "tiers": [ + { + "inputUsdPerMillionTokens": 0.075, + "outputUsdPerMillionTokens": 0.3 + } + ] + }, + { + "providerId": "google", + "modelId": "gemini-3-pro-latest", + "tiers": [ + { + "maxInputTokens": 200000, + "inputUsdPerMillionTokens": 2.0, + "outputUsdPerMillionTokens": 12.0 + }, + { + "inputUsdPerMillionTokens": 4.0, + "outputUsdPerMillionTokens": 18.0 + } + ] + } + ] +} diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/AgentLLMService.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/AgentLLMService.kt index 876b1d16..d6991849 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/AgentLLMService.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/AgentLLMService.kt @@ -17,6 +17,7 @@ import link.socket.ampere.agents.domain.routing.RoutingResolution import link.socket.ampere.agents.events.api.AgentEventApi import link.socket.ampere.agents.events.utils.generateUUID import link.socket.ampere.api.model.TokenUsage +import link.socket.ampere.domain.ai.pricing.ProviderPricingCalculator import link.socket.ampere.domain.llm.LlmProvider import link.socket.ampere.domain.util.toClientModelId import link.socket.ampere.util.ioDispatcher @@ -205,7 +206,11 @@ class AgentLLMService( } } - val usage = TokenUsageExtractor.fromOpenAiUsage(completion.usage) + val usage = enrichUsageWithEstimatedCost( + providerId = effectiveConfig.provider.id, + modelId = model.name, + usage = TokenUsageExtractor.fromOpenAiUsage(completion.usage), + ) emitCompletedTelemetry( routingContext = routingContext, @@ -384,6 +389,41 @@ class AgentLLMService( private fun resolveCustomProviderId(): String = runCatching { agentConfiguration.aiConfiguration.provider.id } .getOrDefault("custom") + + internal suspend fun enrichUsageWithEstimatedCost( + providerId: String, + modelId: String, + usage: TokenUsage, + estimateUsd: suspend (providerId: String, modelId: String, inputTokens: Int?, outputTokens: Int?) -> Double? = + { estimateProviderId, estimateModelId, inputTokens, outputTokens -> + ProviderPricingCalculator.estimateUsd( + providerId = estimateProviderId, + modelId = estimateModelId, + inputTokens = inputTokens, + outputTokens = outputTokens, + ) + }, + ): TokenUsage { + val bundledEstimatedCost = runCatching { + estimateUsd( + providerId, + modelId, + usage.inputTokens, + usage.outputTokens, + ) + }.getOrElse { error -> + logger.w(error) { + "[LLM] Failed to resolve bundled pricing for $providerId/$modelId" + } + null + } + + return if (bundledEstimatedCost != null) { + usage.copy(estimatedCost = bundledEstimatedCost) + } else { + usage + } + } } /** diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/TokenUsageExtractor.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/TokenUsageExtractor.kt index 7d1101c9..014574f5 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/TokenUsageExtractor.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/reasoning/TokenUsageExtractor.kt @@ -10,8 +10,9 @@ import link.socket.ampere.api.model.TokenUsage /** * Maps provider-specific usage metadata into AMPERE's stable [TokenUsage] contract. * - * Cost remains nullable because not every provider reports it, and AMPERE does not - * currently maintain local pricing tables for inference. + * Cost remains nullable because not every provider reports it. Provider-reported + * values are preserved here and can be overridden later by bundled pricing when + * AMPERE has a matching provider/model entry. */ internal object TokenUsageExtractor { diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCalculator.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCalculator.kt new file mode 100644 index 00000000..aba470f2 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCalculator.kt @@ -0,0 +1,41 @@ +package link.socket.ampere.domain.ai.pricing + +internal object ProviderPricingCalculator { + suspend fun estimateUsd( + providerId: String, + modelId: String, + inputTokens: Int?, + outputTokens: Int?, + ): Double? { + val pricing = BundledProviderPricingCatalog.find( + providerId = providerId, + modelId = modelId, + ) + + return estimateUsd( + pricing = pricing, + inputTokens = inputTokens, + outputTokens = outputTokens, + ) + } + + fun estimateUsd( + pricing: ProviderModelPricing?, + inputTokens: Int?, + outputTokens: Int?, + ): Double? { + if (inputTokens == null || outputTokens == null) return null + if (inputTokens < 0 || outputTokens < 0) return null + + val tier = pricing?.tiers?.firstOrNull { candidate -> + candidate.maxInputTokens == null || inputTokens <= candidate.maxInputTokens + } ?: return null + + return tier.inputUsdPerMillionTokens.costFor(inputTokens) + + tier.outputUsdPerMillionTokens.costFor(outputTokens) + } + + private fun Double.costFor(tokens: Int): Double = this * tokens.toDouble() / ONE_MILLION_TOKENS + + private const val ONE_MILLION_TOKENS = 1_000_000.0 +} 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 new file mode 100644 index 00000000..52a6f665 --- /dev/null +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.kt @@ -0,0 +1,78 @@ +package link.socket.ampere.domain.ai.pricing + +import kotlinx.serialization.Serializable +import link.socket.ampere.data.DEFAULT_JSON +import link.socket.ampere.resources.Res +import org.jetbrains.compose.resources.ExperimentalResourceApi + +@Serializable +data class ProviderPricingCatalog( + val version: Int, + val currency: String, + val entries: List, +) + +@Serializable +data class ProviderModelPricing( + val providerId: String, + val modelId: String, + val tiers: List, +) + +@Serializable +data class TokenPricingTier( + val maxInputTokens: Int? = null, + val inputUsdPerMillionTokens: Double, + val outputUsdPerMillionTokens: Double, +) + +@OptIn(ExperimentalResourceApi::class) +internal object BundledProviderPricingCatalog { + private const val RESOURCE_PATH = "files/provider_pricing.v1.json" + private const val RESOURCE_PACKAGE_PATH = "composeResources/link.socket.ampere.resources/$RESOURCE_PATH" + + private var cachedCatalog: IndexedProviderPricingCatalog? = null + + suspend fun load(): ProviderPricingCatalog = loadIndexedCatalog().catalog + + suspend fun find(providerId: String, modelId: String): ProviderModelPricing? = + loadIndexedCatalog().entriesByKey[ProviderModelKey(providerId = providerId, modelId = modelId)] + + private suspend fun loadIndexedCatalog(): IndexedProviderPricingCatalog { + cachedCatalog?.let { return it } + + val json = runCatching { + Res.readBytes(RESOURCE_PATH).decodeToString() + }.getOrElse { composeResourceError -> + loadBundledProviderPricingFallback( + resourcePath = RESOURCE_PACKAGE_PATH, + fallbackPath = RESOURCE_PATH, + ) ?: throw composeResourceError + } + val catalog = DEFAULT_JSON.decodeFromString(ProviderPricingCatalog.serializer(), json) + return IndexedProviderPricingCatalog( + catalog = catalog, + entriesByKey = catalog.entries.associateBy { + ProviderModelKey( + providerId = it.providerId, + modelId = it.modelId, + ) + }, + ).also { cachedCatalog = it } + } +} + +internal expect suspend fun loadBundledProviderPricingFallback( + resourcePath: String, + fallbackPath: String, +): String? + +private data class IndexedProviderPricingCatalog( + val catalog: ProviderPricingCatalog, + val entriesByKey: Map, +) + +private data class ProviderModelKey( + val providerId: String, + val modelId: String, +) diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/reasoning/AgentLLMServicePricingTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/reasoning/AgentLLMServicePricingTest.kt new file mode 100644 index 00000000..e8e7aac4 --- /dev/null +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/agents/domain/reasoning/AgentLLMServicePricingTest.kt @@ -0,0 +1,60 @@ +package link.socket.ampere.agents.domain.reasoning + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlinx.coroutines.test.runTest +import link.socket.ampere.agents.config.AgentConfiguration +import link.socket.ampere.agents.config.CognitiveConfig +import link.socket.ampere.api.model.TokenUsage +import link.socket.ampere.domain.agent.bundled.WriteCodeAgent +import link.socket.ampere.domain.ai.configuration.AIConfiguration_Default +import link.socket.ampere.domain.ai.model.AIModel_OpenAI +import link.socket.ampere.domain.ai.provider.AIProvider_OpenAI + +class AgentLLMServicePricingTest { + + private val service = AgentLLMService( + agentConfiguration = AgentConfiguration( + agentDefinition = WriteCodeAgent, + aiConfiguration = AIConfiguration_Default( + provider = AIProvider_OpenAI, + model = AIModel_OpenAI.GPT_4_1, + ), + cognitiveConfig = CognitiveConfig(), + ), + ) + + @Test + fun `enriches token usage with bundled cost for known model`() = runTest { + val usage = service.enrichUsageWithEstimatedCost( + providerId = "openai", + modelId = "gpt-4.1", + usage = TokenUsage( + inputTokens = 1_000, + outputTokens = 500, + ), + estimateUsd = { _, _, inputTokens, outputTokens -> + if (inputTokens == 1_000 && outputTokens == 500) 0.006 else null + }, + ) + + assertEquals(0.006, assertNotNull(usage.estimatedCost), absoluteTolerance = 0.0000001) + } + + @Test + fun `preserves provider reported cost when bundled pricing is unavailable`() = runTest { + val usage = service.enrichUsageWithEstimatedCost( + providerId = "openai", + modelId = "unknown-model", + usage = TokenUsage( + inputTokens = 1_000, + outputTokens = 500, + estimatedCost = 0.42, + ), + estimateUsd = { _, _, _, _ -> null }, + ) + + assertEquals(0.42, usage.estimatedCost) + } +} diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCalculatorTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCalculatorTest.kt new file mode 100644 index 00000000..f95e8674 --- /dev/null +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCalculatorTest.kt @@ -0,0 +1,58 @@ +package link.socket.ampere.domain.ai.pricing + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ProviderPricingCalculatorTest { + + private val pricing = ProviderModelPricing( + providerId = "google", + modelId = "gemini-2.5-pro", + tiers = listOf( + TokenPricingTier( + maxInputTokens = 200_000, + inputUsdPerMillionTokens = 1.25, + outputUsdPerMillionTokens = 10.0, + ), + TokenPricingTier( + inputUsdPerMillionTokens = 2.5, + outputUsdPerMillionTokens = 15.0, + ), + ), + ) + + @Test + fun `calculates cost for known model token counts`() { + val estimatedCost = ProviderPricingCalculator.estimateUsd( + pricing = pricing, + inputTokens = 1_000, + outputTokens = 500, + ) + + assertEquals(0.00625, assertNotNull(estimatedCost), absoluteTolerance = 0.0000001) + } + + @Test + fun `uses higher tier pricing when input crosses threshold`() { + val estimatedCost = ProviderPricingCalculator.estimateUsd( + pricing = pricing, + inputTokens = 250_000, + outputTokens = 10_000, + ) + + assertEquals(0.775, assertNotNull(estimatedCost), absoluteTolerance = 0.0000001) + } + + @Test + fun `unknown model returns no estimate`() { + val estimatedCost = ProviderPricingCalculator.estimateUsd( + pricing = null, + inputTokens = 1_000, + outputTokens = 500, + ) + + assertNull(estimatedCost) + } +} diff --git a/ampere-core/src/iosMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.ios.kt b/ampere-core/src/iosMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.ios.kt new file mode 100644 index 00000000..5bed23f5 --- /dev/null +++ b/ampere-core/src/iosMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.ios.kt @@ -0,0 +1,6 @@ +package link.socket.ampere.domain.ai.pricing + +internal actual suspend fun loadBundledProviderPricingFallback( + resourcePath: String, + fallbackPath: String, +): String? = null diff --git a/ampere-core/src/jsMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.js.kt b/ampere-core/src/jsMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.js.kt new file mode 100644 index 00000000..5bed23f5 --- /dev/null +++ b/ampere-core/src/jsMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.js.kt @@ -0,0 +1,6 @@ +package link.socket.ampere.domain.ai.pricing + +internal actual suspend fun loadBundledProviderPricingFallback( + resourcePath: String, + fallbackPath: String, +): String? = null diff --git a/ampere-core/src/jvmMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.jvm.kt b/ampere-core/src/jvmMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.jvm.kt new file mode 100644 index 00000000..36027e7d --- /dev/null +++ b/ampere-core/src/jvmMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.jvm.kt @@ -0,0 +1,38 @@ +package link.socket.ampere.domain.ai.pricing + +internal actual suspend fun loadBundledProviderPricingFallback( + resourcePath: String, + fallbackPath: String, +): String? = readBundledProviderPricingFromClasspath( + resourcePath = resourcePath, + fallbackPath = fallbackPath, +) + +internal fun readBundledProviderPricingFromClasspath( + resourcePath: String, + fallbackPath: String, +): String? { + val candidatePaths = listOf( + resourcePath, + fallbackPath, + resourcePath.removePrefix("/"), + fallbackPath.removePrefix("/"), + resourcePath.substringAfterLast('/'), + fallbackPath.substringAfterLast('/'), + ).distinct() + + val classLoaders = listOfNotNull( + Thread.currentThread().contextClassLoader, + BundledProviderPricingCatalog::class.java.classLoader, + ) + + for (classLoader in classLoaders) { + for (candidate in candidatePaths) { + classLoader.getResourceAsStream(candidate)?.use { stream -> + return stream.readBytes().decodeToString() + } + } + } + + return null +} diff --git a/ampere-core/src/jvmTest/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalogClasspathTest.kt b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalogClasspathTest.kt new file mode 100644 index 00000000..68a2307d --- /dev/null +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalogClasspathTest.kt @@ -0,0 +1,19 @@ +package link.socket.ampere.domain.ai.pricing + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertNotNull + +class ProviderPricingCatalogClasspathTest { + + @Test + fun `classpath fallback can read bundled pricing resource`() { + val json = readBundledProviderPricingFromClasspath( + resourcePath = "composeResources/link.socket.ampere.resources/files/provider_pricing.v1.json", + fallbackPath = "files/provider_pricing.v1.json", + ) + + assertNotNull(json) + assertContains(json, "\"providerId\": \"openai\"") + } +} 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 new file mode 100644 index 00000000..1473853d --- /dev/null +++ b/ampere-core/src/jvmTest/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalogTest.kt @@ -0,0 +1,44 @@ +package link.socket.ampere.domain.ai.pricing + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class ProviderPricingCatalogTest { + + @Test + fun `bundled pricing resource parses successfully`() = runTest { + val catalog = BundledProviderPricingCatalog.load() + + assertEquals(1, catalog.version) + assertEquals("USD", catalog.currency) + assertTrue(catalog.entries.isNotEmpty()) + } + + @Test + fun `known provider and model pairs resolve to non zero pricing`() = runTest { + val openAiPricing = BundledProviderPricingCatalog.find( + providerId = "openai", + modelId = "gpt-4.1", + ) + val anthropicPricing = BundledProviderPricingCatalog.find( + providerId = "anthropic", + modelId = "claude-sonnet-4-0", + ) + val googlePricing = BundledProviderPricingCatalog.find( + providerId = "google", + modelId = "gemini-2.5-flash", + ) + + assertNotNull(openAiPricing) + assertNotNull(anthropicPricing) + assertNotNull(googlePricing) + assertTrue(openAiPricing.tiers.any { it.inputUsdPerMillionTokens > 0.0 && it.outputUsdPerMillionTokens > 0.0 }) + assertTrue( + anthropicPricing.tiers.any { it.inputUsdPerMillionTokens > 0.0 && it.outputUsdPerMillionTokens > 0.0 }, + ) + assertTrue(googlePricing.tiers.any { it.inputUsdPerMillionTokens > 0.0 && it.outputUsdPerMillionTokens > 0.0 }) + } +} diff --git a/ampere-core/src/wasmJsMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.wasmJs.kt b/ampere-core/src/wasmJsMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.wasmJs.kt new file mode 100644 index 00000000..5bed23f5 --- /dev/null +++ b/ampere-core/src/wasmJsMain/kotlin/link/socket/ampere/domain/ai/pricing/ProviderPricingCatalog.wasmJs.kt @@ -0,0 +1,6 @@ +package link.socket.ampere.domain.ai.pricing + +internal actual suspend fun loadBundledProviderPricingFallback( + resourcePath: String, + fallbackPath: String, +): String? = null