Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
19691c0
feat(stats): add token ledger and migration foundation
CATMIAOZHI Aug 5, 2026
05ce1f1
feat(stats): unify token usage recording
CATMIAOZHI Aug 6, 2026
b181ee9
feat(stats): add token usage aggregation queries
CATMIAOZHI Aug 6, 2026
a5e2979
feat(stats): add token usage dashboard
CATMIAOZHI Aug 7, 2026
50259f7
feat(stats): add safe statistics deletion
CATMIAOZHI Aug 7, 2026
bb31245
feat(stats): improve token dashboard management
CATMIAOZHI Aug 7, 2026
d973cf4
feat(stats): refine pricing and adaptive ranges
CATMIAOZHI Aug 7, 2026
8b69804
feat(stats): add interactive token activity
CATMIAOZHI Aug 8, 2026
33df470
fix(stats): preserve aliases and legacy pricing
CATMIAOZHI Aug 8, 2026
04a9fce
fix(stats): resolve custom provider legacy keys
CATMIAOZHI Aug 8, 2026
f7e4b5e
fix(stats): stream usage and token totals
CATMIAOZHI Aug 8, 2026
190b856
fix(stats): improve custom range picker feedback
CATMIAOZHI Aug 8, 2026
4038b06
fix(stats): harden reliability and token totals
CATMIAOZHI Aug 8, 2026
053123c
fix(stats): close review edge cases
CATMIAOZHI Aug 8, 2026
5ead52c
fix(stats): handle retry and cost edge cases
CATMIAOZHI Aug 8, 2026
90df9f7
fix(stats): preserve evidence and page activity
CATMIAOZHI Aug 8, 2026
05425c3
fix(stats): resolve dev quality failures
CATMIAOZHI Aug 9, 2026
6d31475
refactor(stats): split spool reliability components
CATMIAOZHI Aug 9, 2026
9d08588
fix: resolve audit findings for upstream baseline compatibility
CATMIAOZHI Aug 10, 2026
078a167
fix: sync renamed palette symbols to tests and drop dead migration state
CATMIAOZHI Aug 10, 2026
517d786
fix: adapt stats providers, migration and tests to upstream Long base…
CATMIAOZHI Aug 10, 2026
0b50a91
fix(stats): resolve P1 audit findings for upstream readiness
CATMIAOZHI Aug 10, 2026
c13d251
fix(stats): close backup restore access races
CATMIAOZHI Aug 11, 2026
6bcd232
fix(providers): preserve usage callback cancellation
CATMIAOZHI Aug 11, 2026
663a3a5
Merge remote-tracking branch 'origin/review-pr-922' into fix/token-st…
AAswordman Aug 11, 2026
acdcdcf
fix(backup): preserve database on restore replacement failure
AAswordman Aug 11, 2026
35a8623
Merge pull request #922 from CATMIAOZHI/contrib/token-statistics
AAswordman Aug 11, 2026
27b85e1
Merge GitHub PR #922 baseline into review branch
AAswordman Aug 11, 2026
82d70f8
refactor(token-stats): replace unpublished ledger with Room storage
AAswordman Aug 12, 2026
3d56732
Merge remote-tracking branch 'origin/main' into fix/token-stats-922-r…
AAswordman Aug 13, 2026
8e9bdf6
fix(stats): guard Room access during restore
AAswordman Aug 13, 2026
5235840
fix(stats): serialize database backup access
AAswordman Aug 13, 2026
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
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,10 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.compose.bom))

// 单元测试中真实 org.json(Android 桩在 JVM 测试里会抛 Stub! 异常);
// 统计 usage 归一化测试需要解析 JSONObject。
testImplementation("org.json:json:20240303")

// Apache POI - for Document processing (DOC, DOCX, etc.)
implementation(libs.poi)
implementation(libs.poi.ooxml)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1110,7 +1110,13 @@ class EnhancedAIService private constructor(private val context: Context) {
currentRequestCachedInputTokenCount = cachedInput.coerceAtLeast(0)
_perRequestTokenCounts.value = Pair(input, output)
},
onNonFatalError = onNonFatalError
onNonFatalError = onNonFatalError,
statsCategory =
if (isSubTask) {
com.ai.assistance.operit.data.stats.TokenStatCategory.SUBAGENT
} else {
com.ai.assistance.operit.data.stats.TokenStatCategory.CHAT
}
)
val revisableStream = responseStream as? TextStreamEventCarrier

Expand Down Expand Up @@ -1211,10 +1217,6 @@ class EnhancedAIService private constructor(private val context: Context) {
currentRequestInputTokenCount = 0L
currentRequestOutputTokenCount = 0L
currentRequestCachedInputTokenCount = 0L
apiPreferences.updateTokensForProviderModel(serviceForFunction.providerModel, inputTokens, outputTokens, cachedInputTokens)

// Update request count
apiPreferences.incrementRequestCountForProviderModel(serviceForFunction.providerModel)

AppLogger.d(
TAG,
Expand Down Expand Up @@ -2330,7 +2332,13 @@ class EnhancedAIService private constructor(private val context: Context) {
currentRequestCachedInputTokenCount = cachedInput.coerceAtLeast(0)
_perRequestTokenCounts.value = Pair(input, output)
},
onNonFatalError = onNonFatalError
onNonFatalError = onNonFatalError,
statsCategory =
if (isSubTask) {
com.ai.assistance.operit.data.stats.TokenStatCategory.SUBAGENT
} else {
com.ai.assistance.operit.data.stats.TokenStatCategory.CHAT
}
)

// 更新状态为接收中
Expand Down Expand Up @@ -2425,10 +2433,6 @@ class EnhancedAIService private constructor(private val context: Context) {
currentRequestInputTokenCount = 0L
currentRequestOutputTokenCount = 0L
currentRequestCachedInputTokenCount = 0L
apiPreferences.updateTokensForProviderModel(serviceForFunction.providerModel, inputTokens, outputTokens, cachedInputTokens)

// Update request count
apiPreferences.incrementRequestCountForProviderModel(serviceForFunction.providerModel)

AppLogger.d(
TAG,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,8 @@ class ConversationService(
summaryService.sendMessage(
context = context,
chatHistory = preparedHistory,
modelParameters = modelParameters
modelParameters = modelParameters,
statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.SUMMARY
)

// 收集流中的所有内容
Expand Down Expand Up @@ -320,18 +321,7 @@ class ConversationService(
return "Conversation Summary: Unable to generate valid summary."
}

// 将总结token计数添加到用户偏好分析的token统计中
try {
AppLogger.d(TAG, "总结生成使用了输入token: $inputTokens, 缓存token: $cachedInputTokens, 输出token: $outputTokens")
apiPreferences.updateTokensForProviderModel(summaryService.providerModel, inputTokens, outputTokens, cachedInputTokens)

// Update request count for summary generation
apiPreferences.incrementRequestCountForProviderModel(summaryService.providerModel)

AppLogger.d(TAG, "已将总结token统计添加到用户偏好分析token计数中")
} catch (e: Exception) {
AppLogger.e(TAG, "更新token统计失败", e)
}
AppLogger.d(TAG, "总结生成使用了输入token: $inputTokens, 缓存token: $cachedInputTokens, 输出token: $outputTokens")

return summaryContent
} catch (e: Exception) {
Expand Down Expand Up @@ -368,28 +358,18 @@ class ConversationService(
chatHistory = preparedHistory,
modelParameters = modelParameters,
stream = false,
enableRetry = false
enableRetry = false,
statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.TITLE
).collect { content -> contentBuilder.append(content) }

val title = sanitizeConversationTitle(
ChatUtils.removeThinkingContent(contentBuilder.toString().trim())
)

try {
val inputTokens = titleService.inputTokenCount
val cachedInputTokens = titleService.cachedInputTokenCount
val outputTokens = titleService.outputTokenCount
apiPreferences.updateTokensForProviderModel(
titleService.providerModel,
inputTokens,
outputTokens,
cachedInputTokens
)
apiPreferences.incrementRequestCountForProviderModel(titleService.providerModel)
AppLogger.d(TAG, "标题生成使用了输入token: $inputTokens, 缓存token: $cachedInputTokens, 输出token: $outputTokens")
} catch (e: Exception) {
AppLogger.e(TAG, "更新标题生成token统计失败", e)
}
val inputTokens = titleService.inputTokenCount
val cachedInputTokens = titleService.cachedInputTokenCount
val outputTokens = titleService.outputTokenCount
AppLogger.d(TAG, "标题生成使用了输入token: $inputTokens, 缓存token: $cachedInputTokens, 输出token: $outputTokens")

title
} catch (e: Exception) {
Expand Down Expand Up @@ -1132,7 +1112,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)}
val stream = translationService.sendMessage(
context = context,
chatHistory = chatHistory + PromptTurn(kind = PromptTurnKind.USER, content = translationPrompt),
modelParameters = modelParameters
modelParameters = modelParameters,
statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER
)

stream.collect { content ->
Expand Down Expand Up @@ -1191,7 +1172,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)}
val stream = summaryService.sendMessage(
context = context,
chatHistory = chatHistory + PromptTurn(kind = PromptTurnKind.USER, content = descriptionPrompt),
modelParameters = modelParameters
modelParameters = modelParameters,
statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER
)

stream.collect { content ->
Expand Down Expand Up @@ -1249,7 +1231,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)}
service.sendMessage(
context = context,
chatHistory = listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)),
modelParameters = modelParameters
modelParameters = modelParameters,
statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER
).collect { chunk ->
result.append(chunk)
}
Expand Down Expand Up @@ -1294,7 +1277,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)}
service.sendMessage(
context = context,
chatHistory = listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)),
modelParameters = modelParameters
modelParameters = modelParameters,
statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER
).collect { chunk ->
result.append(chunk)
}
Expand Down Expand Up @@ -1337,7 +1321,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)}
service.sendMessage(
context = context,
chatHistory = listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)),
modelParameters = modelParameters
modelParameters = modelParameters,
statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER
).collect { chunk ->
result.append(chunk)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,22 +256,12 @@ object MemoryLibrary {
val stream =
aiService.sendMessage(
context = context,
chatHistory = messages
chatHistory = messages,
statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.MEMORY
)
stream.collect { content -> result.append(content) }
}

// 更新 token 统计
apiPreferences?.updateTokensForProviderModel(
aiService.providerModel,
aiService.inputTokenCount,
aiService.outputTokenCount,
aiService.cachedInputTokenCount
)

// Update request count
apiPreferences?.incrementRequestCountForProviderModel(aiService.providerModel)


// 解析 AI 返回的 JSON 并更新记忆
parseAndApplyCategorization(result.toString(), memories, repository)
}
Expand Down Expand Up @@ -670,21 +660,12 @@ object MemoryLibrary {
val stream =
aiService.sendMessage(
context = context,
chatHistory = messages
chatHistory = messages,
statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.MEMORY
)
stream.collect { content -> result.append(content) }
}

apiPreferences?.updateTokensForProviderModel(
aiService.providerModel,
aiService.inputTokenCount,
aiService.outputTokenCount,
aiService.cachedInputTokenCount
)

// Update request count
apiPreferences?.incrementRequestCountForProviderModel(aiService.providerModel)

return parseAnalysisResult(ChatUtils.removeThinkingContent(result.toString()))
} catch (e: CancellationException) {
throw e
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import com.ai.assistance.operit.core.chat.hooks.PromptTurn
import com.ai.assistance.operit.data.model.ModelParameter
import com.ai.assistance.operit.data.model.ModelOption
import com.ai.assistance.operit.data.model.ToolPrompt
import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot
import com.ai.assistance.operit.data.stats.TokenStatCategory
import com.ai.assistance.operit.util.stream.Stream

/** AI服务接口,定义与不同AI提供商进行交互的标准方法 */
Expand Down Expand Up @@ -44,8 +46,14 @@ interface AIService {
* @param enableThinking 是否启用思考模式
* @param stream 是否使用流式输出,true为流式,false为非流式(但返回值仍为Stream)
* @param availableTools 可用工具列表(用于Tool Call API),如果为null则使用系统提示词中的工具描述
* @param onTokensUpdated Token更新回调
* @param onTokensUpdated Token更新回调(UI 计数通道,可能携带估算值)
* @param onUsageReported 规范化 usage 上报回调(统计账本通道;只在解析到
* provider 真实 usage/本地实测计数时回调,估算值不上报;可被多次调用,
* 第二次参数为 provider 内部尝试序号 attempt(从 1 开始,内部重试递增),
* 记录方按 attempt 聚合:同一 attempt 取最后一次,不同 attempt 累加)
* @param onNonFatalError 非致命错误回调
* @param enableRetry 是否允许内部重试
* @param statsCategory 业务分类(统计账本);null 表示调用方未声明(按 OTHER 记录)
* @return 流式响应内容的Stream(无论stream参数如何,都返回Stream)
*/
suspend fun sendMessage(
Expand All @@ -57,17 +65,24 @@ interface AIService {
availableTools: List<ToolPrompt>? = null,
preserveThinkInHistory: Boolean = false, // 新增参数,控制是否保留历史中的思考过程
onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit = { _, _, _ -> },
onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)? = null,
onNonFatalError: suspend (error: String) -> Unit = {},
enableRetry: Boolean = true
enableRetry: Boolean = true,
statsCategory: TokenStatCategory? = null
): Stream<String>

/**
* 测试与AI服务的连接
*
* @param context Android Context
* @param onUsageReported 与 [sendMessage] 相同的 usage 上报回调;实现内部
* 通过 sendMessage 发起测试模型调用时必须透传,使探测用量进入统计账本。
* @return 成功时返回成功信息,失败时返回包含错误的Result
*/
suspend fun testConnection(context: Context): Result<String>
suspend fun testConnection(
context: Context,
onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)? = null
): Result<String>

/**
* 精确计算下一次请求的输入Token数量
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,11 @@ object AIServiceFactory {
}

/**
* 创建AI服务实例
* 创建AI服务实例(统一统计记录边界)。
*
* 所有服务(包括连接测试器直接创建的探测服务)都在这里统一包装
* [TokenTrackingAIService]:任何 sendMessage/testConnection 调用都会落入
* 统计账本,业务分类由调用方通过 sendMessage 的 statsCategory 声明。
*
* @param config 模型配置数据
* @param modelConfigManager 模型配置管理器,用于多API Key模式
Expand All @@ -266,6 +270,19 @@ object AIServiceFactory {
config: ModelConfigData,
modelConfigManager: ModelConfigManager,
context: Context
): AIService {
val rawService = buildService(config, modelConfigManager, context)
return TokenTrackingAIService(
delegate = rawService,
context = context,
configId = config.id,
)
}

private fun buildService(
config: ModelConfigData,
modelConfigManager: ModelConfigManager,
context: Context
): AIService {
val providerTypeId = config.apiProviderTypeId.trim()
ToolPkgAiProviderRegistry.get(providerTypeId)?.let { provider ->
Expand Down Expand Up @@ -300,7 +317,21 @@ object AIServiceFactory {

return when (providerType) {
// OpenAI格式,支持原生和兼容OpenAI API的服务
ApiProviderType.OPENAI,
ApiProviderType.OPENAI ->
OpenAIProvider(
apiEndpoint = config.apiEndpoint,
apiKeyProvider = apiKeyProvider,
modelName = config.modelName,
client = httpClient,
customHeaders = customHeaders,
providerType = providerType,
supportsVision = supportsVision,
supportsAudio = supportsAudio,
supportsVideo = supportsVideo,
enableToolCall = enableToolCall,
includeUsageInStream = true,
)

ApiProviderType.OPENAI_GENERIC,
ApiProviderType.OPENAI_LOCAL ->
OpenAIProvider(
Expand Down
Loading
Loading