Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import link.socket.ampere.agents.domain.event.Event
import link.socket.ampere.agents.domain.event.EventSource
import link.socket.ampere.agents.domain.event.CognitiveStateSnapshot
import link.socket.ampere.agents.domain.event.EventSource
import link.socket.ampere.agents.domain.event.ProviderCallCompletedEvent
import link.socket.ampere.agents.domain.event.SparkAppliedEvent
import link.socket.ampere.agents.domain.event.SparkRemovedEvent
import link.socket.ampere.agents.domain.Urgency
import link.socket.ampere.api.model.TokenUsage
import org.junit.jupiter.api.Test
import java.io.ByteArrayOutputStream
import java.io.PrintStream
Expand Down Expand Up @@ -181,6 +183,32 @@ class EventRendererTest {
availableToolCount = availableToolCount
)

private fun providerCallCompletedEvent(
eventId: String = "evt-llm-1",
timestamp: Instant = Clock.System.now(),
source: EventSource = EventSource.Agent("agent-test"),
urgency: Urgency = Urgency.LOW,
usage: TokenUsage = TokenUsage(
inputTokens = 1247,
outputTokens = 892,
estimatedCost = 0.0031,
),
latencyMs: Long = 340,
success: Boolean = true,
): ProviderCallCompletedEvent = ProviderCallCompletedEvent(
eventId = eventId,
timestamp = timestamp,
eventSource = source,
urgency = urgency,
workflowId = "wf-render",
agentId = "agent-test",
providerId = "openai",
modelId = "gpt-4.1",
usage = usage,
latencyMs = latencyMs,
success = success,
)

@Test
fun `render TaskCreated event shows task ID, description, and assignment`() {
val output = captureTerminalOutput { _, renderer ->
Expand Down Expand Up @@ -265,6 +293,19 @@ class EventRendererTest {
assertContains(lowUrgencyOutput, "[LOW]")
}

@Test
fun `render completed provider call shows usage cost and latency`() {
val output = captureTerminalOutput { _, renderer ->
renderer.render(providerCallCompletedEvent())
}

val rendered = stripAnsi(output)
assertContains(rendered, "1,247 in / 892 out")
assertContains(rendered, "~\$0.0031")
assertContains(rendered, "340ms")
assertContains(rendered, "ProviderCallCompleted")
}

@Test
fun `render shows event source for agent events`() {
val output = captureTerminalOutput { _, renderer ->
Expand Down
12 changes: 8 additions & 4 deletions ampere-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ android {
}
}

tasks.register("kotlinConfiguration") {
val kotlinConfiguration = tasks.register("kotlinConfiguration") {
val generatedSources = File(layout.buildDirectory.asFile.get(), "generated/kotlin/config")
generatedSources.mkdirs()
kotlin.sourceSets.commonMain.get().kotlin.srcDirs(generatedSources)
Expand All @@ -265,9 +265,13 @@ tasks.register("kotlinConfiguration") {
)
}

tasks.findByName("build")?.dependsOn(
tasks.findByName("kotlinConfiguration"),
)
tasks.matching { task ->
task.name.startsWith("compile") && task.name.contains("Kotlin")
}.configureEach {
dependsOn(kotlinConfiguration)
}

tasks.findByName("build")?.dependsOn(kotlinConfiguration)

ktlint {
android.set(true)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package link.socket.ampere.agents.domain.event

import kotlin.math.roundToLong
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
Expand Down Expand Up @@ -86,10 +87,21 @@ data class ProviderCallCompletedEvent(
): String = buildString {
append("LLM call ${if (success) "completed" else "failed"}: $providerId/$modelId")
cognitivePhase?.let { append(" [${it.name}]") }
append(" (${latencyMs}ms")
append(" (")
var wroteDetail = false
if (usage.inputTokens != null || usage.outputTokens != null) {
append(", in=${usage.inputTokens ?: "?"}, out=${usage.outputTokens ?: "?"}")
append(
"${usage.inputTokens?.formatGrouped() ?: "?"} in / ${usage.outputTokens?.formatGrouped() ?: "?"} out",
)
wroteDetail = true
}
usage.estimatedCost?.let { estimatedCost ->
if (wroteDetail) append(", ")
append("~${estimatedCost.formatUsd()}")
wroteDetail = true
}
if (wroteDetail) append(", ")
append("${latencyMs}ms")
append(")")
errorType?.let { append(" [$it]") }
workflowId?.let { append(" workflow=$it") }
Expand All @@ -101,3 +113,37 @@ data class ProviderCallCompletedEvent(
const val EVENT_TYPE: EventType = "ProviderCallCompleted"
}
}

private fun Int.formatGrouped(): String = toString().formatGroupedDigits()

private fun Long.formatGrouped(): String = toString().formatGroupedDigits()

private fun String.formatGroupedDigits(): String {
if (length <= 3) return this

val firstGroupSize = if (length % 3 == 0) 3 else length % 3
return buildString {
append(this@formatGroupedDigits.substring(0, firstGroupSize))
var index = firstGroupSize
while (index < this@formatGroupedDigits.length) {
append(",")
append(this@formatGroupedDigits.substring(index, index + 3))
index += 3
}
}
}

private fun Double.formatUsd(): String {
val scaled = (this * 10_000).roundToLong()
val absoluteScaled = if (scaled < 0) -scaled else scaled
val dollars = absoluteScaled / 10_000
val fraction = (absoluteScaled % 10_000).toString().padStart(4, '0')

return buildString {
if (scaled < 0) append("-")
append("$")
append(dollars.formatGrouped())
append(".")
append(fraction)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -205,16 +205,13 @@ class AgentLLMService(
}
}

val usage = TokenUsageExtractor.fromOpenAiUsage(completion.usage)

emitCompletedTelemetry(
routingContext = routingContext,
providerId = effectiveConfig.provider.id,
modelId = model.name,
usage = completion.usage?.let { usage ->
TokenUsage(
inputTokens = usage.promptTokens,
outputTokens = usage.completionTokens,
)
} ?: TokenUsage(),
usage = usage,
success = true,
startedAt = startedAt,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package link.socket.ampere.agents.domain.reasoning

import ai.koog.prompt.message.ResponseMetaInfo
import com.aallam.openai.api.core.Usage
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonPrimitive
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.
*/
internal object TokenUsageExtractor {

fun fromOpenAiUsage(usage: Usage?): TokenUsage = usage?.let {
TokenUsage(
inputTokens = it.promptTokens,
outputTokens = it.completionTokens,
)
} ?: TokenUsage()

fun fromResponseMetaInfo(metaInfo: ResponseMetaInfo?): TokenUsage = metaInfo?.let {
TokenUsage(
inputTokens = it.inputTokensCount,
outputTokens = it.outputTokensCount,
estimatedCost = extractEstimatedCost(it.metadata),
)
} ?: TokenUsage()

private fun extractEstimatedCost(metadata: JsonObject?): Double? {
if (metadata == null) return null

val candidateKeys = listOf(
"estimatedCost",
"estimated_cost",
"costUsd",
"cost_usd",
"cost",
)

for (key in candidateKeys) {
val value = metadata[key]?.jsonPrimitive?.doubleOrNull
if (value != null) return value
}

return null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package link.socket.ampere.api.service

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.datetime.Instant
import link.socket.ampere.agents.domain.event.Event
import link.socket.ampere.agents.domain.event.FileSystemEvent
Expand Down Expand Up @@ -143,6 +144,16 @@ interface EventService {
}
}

@link.socket.ampere.api.AmpereStableApi
fun EventService.routingEvents(
filters: EventRelayFilters = EventRelayFilters(),
): Flow<RoutingEvent> = observe(filters).filterIsInstance<RoutingEvent>()

@link.socket.ampere.api.AmpereStableApi
fun EventService.completionEvents(
filters: EventRelayFilters = EventRelayFilters(),
): Flow<ProviderCallCompletedEvent> = observe(filters).filterIsInstance<ProviderCallCompletedEvent>()

@link.socket.ampere.api.AmpereStableApi
enum class EventStreamFilter {
ALL,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package link.socket.ampere.agents.domain.reasoning

import ai.koog.prompt.message.ResponseMetaInfo
import com.aallam.openai.api.core.Usage
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.datetime.Clock
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import link.socket.ampere.api.model.TokenUsage

class TokenUsageExtractorTest {

@Test
fun `maps OpenAI usage into AMPERE token usage`() {
val usage = Usage(
promptTokens = 1247,
completionTokens = 892,
totalTokens = 2139,
)

val extracted = TokenUsageExtractor.fromOpenAiUsage(usage)

assertEquals(
TokenUsage(
inputTokens = 1247,
outputTokens = 892,
),
extracted,
)
}

@Test
fun `maps response metadata into AMPERE token usage including cost`() {
val metaInfo = ResponseMetaInfo.create(
clock = Clock.System,
totalTokensCount = 2139,
inputTokensCount = 1247,
outputTokensCount = 892,
metadata = buildJsonObject {
put("estimatedCost", 0.0031)
},
)

val extracted = TokenUsageExtractor.fromResponseMetaInfo(metaInfo)

assertEquals(
TokenUsage(
inputTokens = 1247,
outputTokens = 892,
estimatedCost = 0.0031,
),
extracted,
)
}

@Test
fun `returns empty token usage when provider metadata is absent`() {
assertEquals(TokenUsage(), TokenUsageExtractor.fromOpenAiUsage(null))
assertEquals(TokenUsage(), TokenUsageExtractor.fromResponseMetaInfo(null))
}
}
Loading