mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
Unify model configuration and usage accounting
This commit is contained in:
@@ -28,6 +28,8 @@ import net.mamoe.mirai.message.data.content
|
||||
import net.mamoe.mirai.utils.info
|
||||
import top.jie65535.mirai.command.PluginCommands
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import top.jie65535.mirai.config.ModelConfig
|
||||
import top.jie65535.mirai.config.ModelConfigMigration
|
||||
import top.jie65535.mirai.conversation.ConversationContext
|
||||
import top.jie65535.mirai.conversation.ConversationEngine
|
||||
import top.jie65535.mirai.data.ChatHistoryStore
|
||||
@@ -62,8 +64,19 @@ object JChatGPT : KotlinPlugin(
|
||||
override fun onEnable() {
|
||||
PermissionService.INSTANCE.register(chatPermission, "JChatGPT Chat Permission")
|
||||
PluginConfig.reload()
|
||||
ModelConfig.reload()
|
||||
ModelConfigMigration.migrateLoadedConfig().takeIf { it.changed }?.let { migration ->
|
||||
logger.info(
|
||||
"已将旧模型配置自动迁移到 Models.yml:新增 ${migration.addedProviders} 个提供商、" +
|
||||
"${migration.addedModels} 个模型别名"
|
||||
)
|
||||
}
|
||||
PluginData.reload()
|
||||
TokenUsageStore.init(dataFolder)
|
||||
runCatching {
|
||||
TokenUsageStore.init(dataFolder) { message, cause ->
|
||||
if (cause == null) logger.warning(message) else logger.warning(message, cause)
|
||||
}
|
||||
}.onFailure { logger.error("初始化 SQLite 模型用量记录失败,用量统计将暂时禁用", it) }
|
||||
SkillStore.init(dataFolder)
|
||||
|
||||
includeHistory = try {
|
||||
@@ -139,6 +152,7 @@ object JChatGPT : KotlinPlugin(
|
||||
ContactSnapshotRefresher.clear()
|
||||
UserProfileStore.close()
|
||||
ContactSnapshotStore.close()
|
||||
TokenUsageStore.close()
|
||||
ChatHistoryStore.close()
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import net.mamoe.mirai.contact.User
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.JChatGPT.reload
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import top.jie65535.mirai.config.ModelConfig
|
||||
import top.jie65535.mirai.config.ModelConfigMigration
|
||||
import top.jie65535.mirai.data.PluginData
|
||||
import top.jie65535.mirai.data.SkillStore
|
||||
import top.jie65535.mirai.data.TokenUsageStore
|
||||
@@ -45,6 +47,8 @@ object PluginCommands : CompositeCommand(
|
||||
@SubCommand
|
||||
suspend fun CommandSender.reload() {
|
||||
PluginConfig.reload()
|
||||
ModelConfig.reload()
|
||||
ModelConfigMigration.migrateLoadedConfig()
|
||||
PluginData.reload()
|
||||
LargeLanguageModels.reload()
|
||||
ProfileDailyMaintenance.reload()
|
||||
@@ -371,89 +375,93 @@ object PluginCommands : CompositeCommand(
|
||||
suspend fun CommandSender.tokens(days: Int = 7) {
|
||||
validateDays(days)
|
||||
|
||||
if (TokenUsageStore.all.isEmpty()) {
|
||||
sendMessage("暂无 Token 使用记录")
|
||||
if (!TokenUsageStore.isAvailable) {
|
||||
sendMessage("Token SQLite 尚未初始化")
|
||||
return
|
||||
}
|
||||
|
||||
val cutoff = calculateCutoffDate(days)
|
||||
val today = LocalDate.now().toString()
|
||||
|
||||
val windowed = TokenUsageStore.all.filter { it.date >= cutoff }
|
||||
if (windowed.isEmpty()) {
|
||||
sendMessage("最近 $days 天无 Token 使用记录")
|
||||
val summary = runCatching { TokenUsageStore.summary(cutoff, rankingLimit = 100) }
|
||||
.getOrElse {
|
||||
sendMessage("读取 Token 使用记录失败:${it.message ?: it::class.simpleName}")
|
||||
return
|
||||
}
|
||||
if (!TokenUsageStore.hasAny(cutoff)) {
|
||||
sendMessage("暂无 Token 使用记录")
|
||||
return
|
||||
}
|
||||
|
||||
// 窗口汇总
|
||||
var prompt = 0L; var completion = 0L; var total = 0L; var cached = 0L
|
||||
var calls = 0; var todayTotal = 0L
|
||||
val users = HashSet<Long>()
|
||||
for (r in windowed) {
|
||||
prompt += r.promptTokens
|
||||
completion += r.completionTokens
|
||||
total += r.totalTokens
|
||||
cached += r.cachedTokens
|
||||
calls += r.callCount
|
||||
users.add(r.userId)
|
||||
if (r.date == today) todayTotal += r.totalTokens
|
||||
}
|
||||
val hitRate = if (prompt > 0) cached * 100.0 / prompt else 0.0
|
||||
|
||||
// 每日趋势
|
||||
val daily = windowed.groupBy { it.date }
|
||||
.mapValues { (_, rs) -> rs.sumOf { it.totalTokens } }
|
||||
.toSortedMap()
|
||||
|
||||
// Top 用户
|
||||
val topUsers = windowed.groupBy { it.userId }
|
||||
.map { (_, rs) ->
|
||||
val name = rs.maxByOrNull { it.date }!!.userNickname
|
||||
name to rs.sumOf { it.totalTokens }
|
||||
}
|
||||
.sortedByDescending { it.second }
|
||||
.take(TOP_LIMIT)
|
||||
|
||||
// Top 群组:只显示群名,绝不暴露群号(避免被误判宣群)
|
||||
val topGroups = windowed.filter { it.groupId != null }
|
||||
.groupBy { it.groupId!! }
|
||||
.map { (gid, rs) ->
|
||||
val name = rs.firstNotNullOfOrNull { r -> r.groupName?.takeIf { it.isNotBlank() } }
|
||||
?: resolveGroupName(gid)
|
||||
name to rs.sumOf { it.totalTokens }
|
||||
}
|
||||
.sortedByDescending { it.second }
|
||||
.take(TOP_LIMIT)
|
||||
val hitRate = if (summary.promptTokens > 0) {
|
||||
summary.cachedTokens * 100.0 / summary.promptTokens
|
||||
} else 0.0
|
||||
|
||||
val response = buildString {
|
||||
appendLine("📊 Token 简报 · 最近 $days 天")
|
||||
appendLine()
|
||||
appendLine("输入 ${formatCompact(prompt)}(缓存命中 ${"%.1f".format(hitRate)}%,省 ${formatCompact(cached)})")
|
||||
appendLine("输出 ${formatCompact(completion)}")
|
||||
appendLine("总计 ${formatCompact(total)} | 调用 ${formatNumber(calls)} 次 | 活跃 ${users.size} 人")
|
||||
appendLine("今日 ${formatCompact(todayTotal)}")
|
||||
appendLine("输入 ${formatCompact(summary.promptTokens)}(缓存命中 ${"%.1f".format(hitRate)}%,省 ${formatCompact(summary.cachedTokens)})")
|
||||
appendLine("输出 ${formatCompact(summary.completionTokens)}")
|
||||
appendLine("总计 ${formatCompact(summary.totalTokens)} | 调用 ${formatNumber(summary.callCount)} 次 | 活跃 ${summary.activeUsers} 人")
|
||||
if (summary.allCallCount != summary.callCount) {
|
||||
appendLine("全部模型调用 ${formatNumber(summary.allCallCount)} 次")
|
||||
}
|
||||
appendLine("今日 ${formatCompact(summary.todayTotal)}")
|
||||
|
||||
if (daily.size > 1) {
|
||||
if (summary.daily.size > 1) {
|
||||
appendLine()
|
||||
appendLine("📈 每日趋势")
|
||||
daily.forEach { (date, t) ->
|
||||
appendLine(" ${date.substring(5)} ${formatCompact(t)}")
|
||||
summary.daily.forEach { daily ->
|
||||
appendLine(" ${daily.date.substring(5)} ${formatCompact(daily.totalTokens)}")
|
||||
}
|
||||
}
|
||||
|
||||
if (topUsers.isNotEmpty()) {
|
||||
if (summary.topUsers.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine("👤 Top 用户")
|
||||
topUsers.forEachIndexed { i, (name, t) ->
|
||||
appendLine(" ${i + 1}. $name ${formatCompact(t)}")
|
||||
summary.topUsers.forEachIndexed { i, ranking ->
|
||||
appendLine(" ${i + 1}. ${ranking.name.ifBlank { ranking.id.toString() }} ${formatCompact(ranking.totalTokens)}")
|
||||
}
|
||||
}
|
||||
|
||||
if (topGroups.isNotEmpty()) {
|
||||
if (summary.topGroups.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine("👥 Top 群组")
|
||||
topGroups.forEachIndexed { i, (name, t) ->
|
||||
appendLine(" ${i + 1}. $name ${formatCompact(t)}")
|
||||
summary.topGroups.forEachIndexed { i, ranking ->
|
||||
val name = ranking.name.ifBlank { resolveGroupName(ranking.id) }
|
||||
appendLine(" ${i + 1}. $name ${formatCompact(ranking.totalTokens)}")
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.models.size > 1) {
|
||||
appendLine()
|
||||
appendLine("🤖 模型")
|
||||
summary.models.take(TOP_LIMIT).forEach { model ->
|
||||
appendLine(" ${model.provider}/${model.model} ${formatCompact(model.totalTokens)}")
|
||||
}
|
||||
}
|
||||
|
||||
val tokenUsageByKind = summary.breakdown.asSequence()
|
||||
.filter { it.unit == "tokens" }
|
||||
.groupBy { it.usageKind }
|
||||
.mapValues { (_, usage) -> usage.sumOf { it.totalUnits } }
|
||||
.entries
|
||||
.sortedByDescending { it.value }
|
||||
if (tokenUsageByKind.size > 1 || tokenUsageByKind.firstOrNull()?.key != "chat") {
|
||||
appendLine()
|
||||
appendLine("Token 用途")
|
||||
tokenUsageByKind.take(TOP_LIMIT).forEach { (kind, total) ->
|
||||
appendLine(" $kind ${formatCompact(total)}")
|
||||
}
|
||||
}
|
||||
|
||||
val otherUsage = summary.breakdown.filter { it.unit != "tokens" }
|
||||
if (otherUsage.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine("其他模型用量")
|
||||
otherUsage.take(TOP_LIMIT).forEach { usage ->
|
||||
appendLine(
|
||||
" ${usage.provider}/${usage.model} ${usage.usageKind} " +
|
||||
"${formatCompact(usage.totalUnits)} ${usage.unit}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package top.jie65535.mirai.config
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.mamoe.mirai.console.data.AutoSavePluginConfig
|
||||
import net.mamoe.mirai.console.data.ValueDescription
|
||||
import net.mamoe.mirai.console.data.value
|
||||
|
||||
/** A credential/API definition shared by one or more model aliases. */
|
||||
@Serializable
|
||||
data class ModelProviderDefinition(
|
||||
val name: String = "",
|
||||
val type: String = "openai",
|
||||
val api: String = "",
|
||||
val token: String = "",
|
||||
)
|
||||
|
||||
/** A model alias used by role bindings in [PluginConfig]. */
|
||||
@Serializable
|
||||
data class ModelDefinition(
|
||||
val name: String = "",
|
||||
val provider: String = "",
|
||||
val model: String = "",
|
||||
val extraBody: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Shared model catalog. Credentials live here so roles can reference one alias
|
||||
* without duplicating API URLs and keys in the main plugin configuration.
|
||||
*/
|
||||
object ModelConfig : AutoSavePluginConfig("Models") {
|
||||
@ValueDescription(
|
||||
"""模型提供商与凭据;type 可用 openai 或 dashscope,token 对应 API Token/Key。
|
||||
新安装可参考:
|
||||
- name: deepseek
|
||||
type: openai
|
||||
api: 'https://api.deepseek.com/v1/'
|
||||
token: 'sk-xxxx'
|
||||
- name: dashscope-native
|
||||
type: dashscope
|
||||
api: ''
|
||||
token: 'sk-xxxx'"""
|
||||
)
|
||||
var providers: List<ModelProviderDefinition> by value()
|
||||
|
||||
@ValueDescription(
|
||||
"""模型别名;每项通过 provider 绑定一个提供商,并填写实际模型名。
|
||||
配置后还需在 Config.yml 将 chatModelAlias 等用途字段设为对应 name。
|
||||
示例:
|
||||
- name: chat-main
|
||||
provider: deepseek
|
||||
model: deepseek-chat
|
||||
extraBody: ''
|
||||
- name: image-main
|
||||
provider: dashscope-native
|
||||
model: qwen-image-2.0
|
||||
extraBody: ''"""
|
||||
)
|
||||
var models: List<ModelDefinition> by value()
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package top.jie65535.mirai.config
|
||||
|
||||
import java.net.URI
|
||||
|
||||
internal data class ModelRoleBindings(
|
||||
val chat: String = "",
|
||||
val chatFallbacks: List<String> = emptyList(),
|
||||
val profile: String = "",
|
||||
val reasoning: String = "",
|
||||
val visual: String = "",
|
||||
val webSummary: String = "",
|
||||
val image: String = "",
|
||||
val tts: String = "",
|
||||
)
|
||||
|
||||
internal data class LegacyOpenAiModel(
|
||||
val api: String,
|
||||
val token: String,
|
||||
val model: String,
|
||||
val extraBody: String = "",
|
||||
)
|
||||
|
||||
internal data class LegacyModelSettings(
|
||||
val chat: LegacyOpenAiModel,
|
||||
val chatFallbacks: List<LegacyOpenAiModel>,
|
||||
val profile: LegacyOpenAiModel,
|
||||
val reasoning: LegacyOpenAiModel,
|
||||
val visual: LegacyOpenAiModel,
|
||||
val webSummary: LegacyOpenAiModel,
|
||||
val dashScopeToken: String,
|
||||
val imageModel: String,
|
||||
val ttsModel: String,
|
||||
)
|
||||
|
||||
internal data class ModelConfigMigrationResult(
|
||||
val providers: List<ModelProviderDefinition>,
|
||||
val models: List<ModelDefinition>,
|
||||
val bindings: ModelRoleBindings,
|
||||
val addedProviders: Int,
|
||||
val addedModels: Int,
|
||||
val bindingsChanged: Boolean,
|
||||
) {
|
||||
val changed: Boolean
|
||||
get() = addedProviders > 0 || addedModels > 0 || bindingsChanged
|
||||
}
|
||||
|
||||
internal object ModelConfigMigration {
|
||||
fun migrateLoadedConfig(): ModelConfigMigrationResult {
|
||||
val currentBindings = ModelRoleBindings(
|
||||
chat = PluginConfig.chatModelAlias,
|
||||
chatFallbacks = PluginConfig.chatFallbackModelAliases,
|
||||
profile = PluginConfig.profileModelAlias,
|
||||
reasoning = PluginConfig.reasoningModelAlias,
|
||||
visual = PluginConfig.visualModelAlias,
|
||||
webSummary = PluginConfig.webSummaryModelAlias,
|
||||
image = PluginConfig.imageModelAlias,
|
||||
tts = PluginConfig.ttsModelAlias,
|
||||
)
|
||||
val chat = LegacyOpenAiModel(
|
||||
api = PluginConfig.openAiApi,
|
||||
token = PluginConfig.openAiToken,
|
||||
model = PluginConfig.chatModel,
|
||||
extraBody = PluginConfig.chatModelExtraBody,
|
||||
)
|
||||
val legacy = LegacyModelSettings(
|
||||
chat = chat,
|
||||
chatFallbacks = PluginConfig.chatFallbacks.map { fallback ->
|
||||
LegacyOpenAiModel(
|
||||
api = fallback.api.ifBlank { chat.api },
|
||||
token = fallback.token.ifBlank { chat.token },
|
||||
model = fallback.model.ifBlank { chat.model },
|
||||
extraBody = fallback.extraBody.ifBlank { chat.extraBody },
|
||||
)
|
||||
},
|
||||
profile = LegacyOpenAiModel(
|
||||
api = PluginConfig.profileModelApi.ifBlank { chat.api },
|
||||
token = PluginConfig.profileModelToken.ifBlank { chat.token },
|
||||
model = PluginConfig.profileModel.ifBlank { chat.model },
|
||||
extraBody = PluginConfig.profileModelExtraBody.ifBlank { chat.extraBody },
|
||||
),
|
||||
reasoning = LegacyOpenAiModel(
|
||||
api = PluginConfig.reasoningModelApi,
|
||||
token = PluginConfig.reasoningModelToken,
|
||||
model = PluginConfig.reasoningModel,
|
||||
extraBody = PluginConfig.reasoningModelExtraBody,
|
||||
),
|
||||
visual = LegacyOpenAiModel(
|
||||
api = PluginConfig.visualModelApi,
|
||||
token = PluginConfig.visualModelToken,
|
||||
model = PluginConfig.visualModel,
|
||||
extraBody = PluginConfig.visualModelExtraBody,
|
||||
),
|
||||
webSummary = LegacyOpenAiModel(
|
||||
api = PluginConfig.webSummaryModelApi,
|
||||
token = PluginConfig.webSummaryModelToken,
|
||||
model = PluginConfig.webSummaryModel,
|
||||
extraBody = PluginConfig.webSummaryModelExtraBody,
|
||||
),
|
||||
dashScopeToken = PluginConfig.dashScopeApiKey,
|
||||
imageModel = PluginConfig.imageModel,
|
||||
ttsModel = PluginConfig.ttsModel,
|
||||
)
|
||||
val result = migrate(ModelConfig.providers, ModelConfig.models, currentBindings, legacy)
|
||||
if (result.providers != ModelConfig.providers) ModelConfig.providers = result.providers
|
||||
if (result.models != ModelConfig.models) ModelConfig.models = result.models
|
||||
if (result.bindings.chat != PluginConfig.chatModelAlias) PluginConfig.chatModelAlias = result.bindings.chat
|
||||
if (result.bindings.chatFallbacks != PluginConfig.chatFallbackModelAliases) {
|
||||
PluginConfig.chatFallbackModelAliases = result.bindings.chatFallbacks
|
||||
}
|
||||
if (result.bindings.profile != PluginConfig.profileModelAlias) PluginConfig.profileModelAlias = result.bindings.profile
|
||||
if (result.bindings.reasoning != PluginConfig.reasoningModelAlias) {
|
||||
PluginConfig.reasoningModelAlias = result.bindings.reasoning
|
||||
}
|
||||
if (result.bindings.visual != PluginConfig.visualModelAlias) PluginConfig.visualModelAlias = result.bindings.visual
|
||||
if (result.bindings.webSummary != PluginConfig.webSummaryModelAlias) {
|
||||
PluginConfig.webSummaryModelAlias = result.bindings.webSummary
|
||||
}
|
||||
if (result.bindings.image != PluginConfig.imageModelAlias) PluginConfig.imageModelAlias = result.bindings.image
|
||||
if (result.bindings.tts != PluginConfig.ttsModelAlias) PluginConfig.ttsModelAlias = result.bindings.tts
|
||||
return result
|
||||
}
|
||||
|
||||
fun migrate(
|
||||
existingProviders: List<ModelProviderDefinition>,
|
||||
existingModels: List<ModelDefinition>,
|
||||
bindings: ModelRoleBindings,
|
||||
legacy: LegacyModelSettings,
|
||||
): ModelConfigMigrationResult {
|
||||
val providers = existingProviders.toMutableList()
|
||||
val models = existingModels.toMutableList()
|
||||
val initialProviderCount = providers.size
|
||||
val initialModelCount = models.size
|
||||
|
||||
fun bindOpenAi(current: String, preferredAlias: String, legacyModel: LegacyOpenAiModel): String =
|
||||
current.ifBlank {
|
||||
addModel(providers, models, preferredAlias, "openai", legacyModel)
|
||||
}
|
||||
|
||||
fun bindDashScope(current: String, preferredAlias: String, model: String): String =
|
||||
current.ifBlank {
|
||||
addModel(
|
||||
providers = providers,
|
||||
models = models,
|
||||
preferredAlias = preferredAlias,
|
||||
providerType = "dashscope",
|
||||
legacyModel = LegacyOpenAiModel("", legacy.dashScopeToken, model),
|
||||
)
|
||||
}
|
||||
|
||||
val chat = bindOpenAi(bindings.chat, "chat-main", legacy.chat)
|
||||
val chatFallbacks = if (bindings.chatFallbacks.isNotEmpty()) {
|
||||
bindings.chatFallbacks
|
||||
} else {
|
||||
legacy.chatFallbacks.mapIndexedNotNull { index, fallback ->
|
||||
bindOpenAi("", "chat-fallback-${index + 1}", fallback).takeIf(String::isNotBlank)
|
||||
}
|
||||
}
|
||||
val migratedBindings = ModelRoleBindings(
|
||||
chat = chat,
|
||||
chatFallbacks = chatFallbacks,
|
||||
profile = bindOpenAi(bindings.profile, "profile-main", legacy.profile),
|
||||
reasoning = bindOpenAi(bindings.reasoning, "reasoning-main", legacy.reasoning),
|
||||
visual = bindOpenAi(bindings.visual, "visual-main", legacy.visual),
|
||||
webSummary = bindOpenAi(bindings.webSummary, "web-summary-main", legacy.webSummary),
|
||||
image = bindDashScope(bindings.image, "image-main", legacy.imageModel),
|
||||
tts = bindDashScope(bindings.tts, "tts-main", legacy.ttsModel),
|
||||
)
|
||||
return ModelConfigMigrationResult(
|
||||
providers = providers,
|
||||
models = models,
|
||||
bindings = migratedBindings,
|
||||
addedProviders = providers.size - initialProviderCount,
|
||||
addedModels = models.size - initialModelCount,
|
||||
bindingsChanged = migratedBindings != bindings,
|
||||
)
|
||||
}
|
||||
|
||||
private fun addModel(
|
||||
providers: MutableList<ModelProviderDefinition>,
|
||||
models: MutableList<ModelDefinition>,
|
||||
preferredAlias: String,
|
||||
providerType: String,
|
||||
legacyModel: LegacyOpenAiModel,
|
||||
): String {
|
||||
val token = legacyModel.token.trim()
|
||||
val modelName = legacyModel.model.trim()
|
||||
val api = legacyModel.api.trim()
|
||||
val extraBody = legacyModel.extraBody.trim()
|
||||
if (token.isEmpty() || modelName.isEmpty() || providerType == "openai" && api.isEmpty()) return ""
|
||||
|
||||
val providerName = findOrAddProvider(providers, providerType, api, token)
|
||||
val reusable = models.firstOrNull { candidate ->
|
||||
candidate.name.isNotBlank() &&
|
||||
models.count { it.name.trim() == candidate.name.trim() } == 1 &&
|
||||
candidate.provider.trim() == providerName &&
|
||||
candidate.model.trim() == modelName &&
|
||||
candidate.extraBody.trim() == extraBody
|
||||
}
|
||||
if (reusable != null) return reusable.name.trim()
|
||||
|
||||
val alias = uniqueName(preferredAlias, models.mapTo(HashSet()) { it.name.trim() })
|
||||
models += ModelDefinition(
|
||||
name = alias,
|
||||
provider = providerName,
|
||||
model = modelName,
|
||||
extraBody = extraBody,
|
||||
)
|
||||
return alias
|
||||
}
|
||||
|
||||
private fun findOrAddProvider(
|
||||
providers: MutableList<ModelProviderDefinition>,
|
||||
type: String,
|
||||
api: String,
|
||||
token: String,
|
||||
): String {
|
||||
val reusable = providers.firstOrNull { candidate ->
|
||||
candidate.name.isNotBlank() &&
|
||||
providers.count { it.name.trim() == candidate.name.trim() } == 1 &&
|
||||
normalizedType(candidate.type) == type &&
|
||||
normalizedApi(candidate.api) == normalizedApi(api) &&
|
||||
candidate.token.trim() == token
|
||||
}
|
||||
if (reusable != null) return reusable.name.trim()
|
||||
|
||||
val name = uniqueName(providerBaseName(type, api), providers.mapTo(HashSet()) { it.name.trim() })
|
||||
providers += ModelProviderDefinition(name = name, type = type, api = api, token = token)
|
||||
return name
|
||||
}
|
||||
|
||||
private fun normalizedType(type: String): String = when (type.trim().lowercase()) {
|
||||
"openai-compatible", "openai_compatible" -> "openai"
|
||||
else -> type.trim().lowercase()
|
||||
}
|
||||
|
||||
private fun normalizedApi(api: String): String = api.trim().trimEnd('/')
|
||||
|
||||
private fun providerBaseName(type: String, api: String): String {
|
||||
if (type == "dashscope") return "dashscope-native"
|
||||
val host = runCatching { URI.create(api).host?.lowercase() }.getOrNull().orEmpty()
|
||||
if (host.contains("deepseek")) return "deepseek"
|
||||
if (host.contains("dashscope")) return "dashscope-openai"
|
||||
if (host.contains("openai")) return "openai"
|
||||
val segment = host.split('.').firstOrNull { it !in setOf("", "api", "www", "v1") }.orEmpty()
|
||||
return segment.replace(Regex("[^a-z0-9]+"), "-").trim('-').ifBlank { "openai" }
|
||||
}
|
||||
|
||||
private fun uniqueName(preferred: String, occupied: Set<String>): String {
|
||||
if (preferred !in occupied) return preferred
|
||||
var suffix = 2
|
||||
while ("$preferred-$suffix" in occupied) suffix++
|
||||
return "$preferred-$suffix"
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("Chat模型温度,默认为null")
|
||||
var chatTemperature: Double? by value(null)
|
||||
|
||||
@ValueDescription("主聊天模型别名;填写后优先从 Models.yml 解析,留空时兼容旧的 openAiApi/openAiToken/chatModel")
|
||||
var chatModelAlias: String by value("")
|
||||
|
||||
@ValueDescription("聊天备用模型别名列表;主模型失败时按顺序切换")
|
||||
var chatFallbackModelAliases: List<String> by value()
|
||||
|
||||
@ValueDescription("推理模型API")
|
||||
var reasoningModelApi: String by value("https://dashscope.aliyuncs.com/compatible-mode/v1/")
|
||||
|
||||
@@ -42,6 +48,9 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("推理模型")
|
||||
var reasoningModel: String by value("qwq-plus")
|
||||
|
||||
@ValueDescription("推理模型别名;留空时兼容旧的推理模型配置")
|
||||
var reasoningModelAlias: String by value("")
|
||||
|
||||
@ValueDescription("视觉模型API")
|
||||
var visualModelApi: String by value("https://dashscope.aliyuncs.com/compatible-mode/v1/")
|
||||
|
||||
@@ -51,6 +60,9 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("视觉模型")
|
||||
var visualModel: String by value("qwen-vl-plus")
|
||||
|
||||
@ValueDescription("视觉模型别名;留空时兼容旧的视觉模型配置")
|
||||
var visualModelAlias: String by value("")
|
||||
|
||||
@ValueDescription("聊天模型额外请求体JSON,会合并到请求体中。例如DeepSeek关闭思维: {\"thinking\": {\"type\": \"disabled\"}}")
|
||||
val chatModelExtraBody: String by value("")
|
||||
|
||||
@@ -69,6 +81,9 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("画像分析模型。留空时继承聊天模型")
|
||||
val profileModel: String by value("")
|
||||
|
||||
@ValueDescription("画像分析模型别名;留空时继承主聊天模型别名或兼容旧配置")
|
||||
var profileModelAlias: String by value("")
|
||||
|
||||
@ValueDescription("画像分析模型额外请求体JSON。留空时继承聊天模型额外请求体")
|
||||
val profileModelExtraBody: String by value("")
|
||||
|
||||
@@ -174,12 +189,18 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("百炼平台图像模型,支持文生图与图像编辑。可选:qwen-image-2.0 / qwen-image-2.0-pro / qwen-image-edit-max / qwen-image-edit-plus 等")
|
||||
val imageModel: String by value("qwen-image-2.0")
|
||||
|
||||
@ValueDescription("图像模型别名;留空时兼容旧的 dashScopeApiKey/imageModel")
|
||||
var imageModelAlias: String by value("")
|
||||
|
||||
@ValueDescription("是否在生成的图片右下角添加 Qwen-Image 水印")
|
||||
val imageWatermark: Boolean by value(false)
|
||||
|
||||
@ValueDescription("百炼平台TTS模型。qwen3-tts-instruct-flash 支持 instructions 指令控制;纯发音可用 qwen3-tts-flash 或 qwen-tts")
|
||||
val ttsModel: String by value("qwen3-tts-instruct-flash")
|
||||
|
||||
@ValueDescription("TTS 模型别名;留空时兼容旧的 dashScopeApiKey/ttsModel")
|
||||
var ttsModelAlias: String by value("")
|
||||
|
||||
@ValueDescription("Jina API Key")
|
||||
val jinaApiKey by value("")
|
||||
|
||||
@@ -195,6 +216,9 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("网页摘要模型名称")
|
||||
val webSummaryModel: String by value("")
|
||||
|
||||
@ValueDescription("网页摘要模型别名;留空时兼容旧的网页摘要模型配置")
|
||||
var webSummaryModelAlias: String by value("")
|
||||
|
||||
@ValueDescription("网页摘要模型额外请求体JSON,会合并到请求体中")
|
||||
val webSummaryModelExtraBody: String by value("")
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.aallam.openai.api.chat.ChatMessage
|
||||
import com.aallam.openai.api.chat.ChatRole
|
||||
import com.aallam.openai.api.chat.ToolCall
|
||||
import com.aallam.openai.api.chat.ToolChoice
|
||||
import com.aallam.openai.api.chat.StreamOptions
|
||||
import com.aallam.openai.api.core.Usage
|
||||
import com.aallam.openai.api.model.ModelId
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -21,7 +22,7 @@ import net.mamoe.mirai.event.events.MessageEvent
|
||||
import net.mamoe.mirai.message.data.source
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import top.jie65535.mirai.data.TokenUsageStore
|
||||
import top.jie65535.mirai.data.ModelUsageRecorder
|
||||
import top.jie65535.mirai.llm.LargeLanguageModels
|
||||
import top.jie65535.mirai.llm.ModelService
|
||||
import top.jie65535.mirai.profile.ProfileAutoMaintenance
|
||||
@@ -50,6 +51,7 @@ import top.jie65535.mirai.tools.VisitWeb
|
||||
import top.jie65535.mirai.tools.VisualAgent
|
||||
import top.jie65535.mirai.tools.WeatherService
|
||||
import top.jie65535.mirai.tools.WebSearch
|
||||
import top.jie65535.mirai.tools.QueryTokenUsageAgent
|
||||
import top.jie65535.mirai.util.RetryBackoff
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -84,6 +86,7 @@ internal object ConversationEngine {
|
||||
AdjustUserFavorabilityAgent(),
|
||||
RequestOwner(),
|
||||
GroupManageAgent(),
|
||||
QueryTokenUsageAgent(),
|
||||
)
|
||||
|
||||
fun clear() {
|
||||
@@ -202,7 +205,8 @@ internal object ConversationEngine {
|
||||
var lastTokenUsage: Usage? = null
|
||||
|
||||
responseFlow.collect { chunk ->
|
||||
val delta = chunk.choices[0].delta ?: return@collect
|
||||
chunk.usage?.let { lastTokenUsage = it }
|
||||
val delta = chunk.choices.firstOrNull()?.delta ?: return@collect
|
||||
delta.reasoningContent?.let { content ->
|
||||
if (reasoningContent == null) reasoningContent = StringBuilder(content)
|
||||
else reasoningContent.append(content)
|
||||
@@ -238,7 +242,6 @@ internal object ConversationEngine {
|
||||
responseToolCalls[index] = current.copy(function = updated)
|
||||
}
|
||||
}
|
||||
chunk.usage?.let { lastTokenUsage = it }
|
||||
}
|
||||
|
||||
streamingOk = true
|
||||
@@ -252,7 +255,7 @@ internal object ConversationEngine {
|
||||
toolCalls = responseToolCalls.ifEmpty { null },
|
||||
reasoningContent = if (responseToolCalls.isNotEmpty()) reasoningContent?.toString() else null,
|
||||
)
|
||||
recordUsage(roundEvent, lastTokenUsage, lastCacheUsage)
|
||||
recordUsage(roundEvent, endpoint, lastTokenUsage, lastCacheUsage)
|
||||
completedRounds++
|
||||
|
||||
if (responseToolCalls.size > toolCallTasks.size) {
|
||||
@@ -455,6 +458,7 @@ internal object ConversationEngine {
|
||||
messages = history,
|
||||
tools = availableTools,
|
||||
toolChoice = ToolChoice.Required,
|
||||
streamOptions = StreamOptions(includeUsage = true),
|
||||
)
|
||||
JChatGPT.logger.info("API Requesting... Model=${endpoint.model} [${endpoint.label}]")
|
||||
return endpoint.service.chatCompletions(request, onCacheUsage)
|
||||
@@ -534,21 +538,19 @@ internal object ConversationEngine {
|
||||
|
||||
private fun recordUsage(
|
||||
event: MessageEvent,
|
||||
endpoint: LargeLanguageModels.ChatEndpoint,
|
||||
usage: Usage?,
|
||||
cacheUsage: ModelService.CacheUsage?,
|
||||
) {
|
||||
usage ?: return
|
||||
val group = (event as? GroupMessageEvent)?.group
|
||||
TokenUsageStore.record(
|
||||
timestamp = OffsetDateTime.now().toEpochSecond(),
|
||||
userId = event.sender.id,
|
||||
userNickname = event.senderName,
|
||||
groupId = group?.id,
|
||||
groupName = group?.name,
|
||||
promptTokens = usage.promptTokens ?: 0,
|
||||
completionTokens = usage.completionTokens ?: 0,
|
||||
totalTokens = usage.totalTokens ?: 0,
|
||||
cachedTokens = cacheUsage?.hitTokens ?: 0,
|
||||
ModelUsageRecorder.recordTokens(
|
||||
event = event,
|
||||
endpointLabel = endpoint.label,
|
||||
modelAlias = endpoint.alias,
|
||||
provider = endpoint.provider,
|
||||
model = endpoint.model,
|
||||
usageKind = "chat",
|
||||
usage = usage,
|
||||
cacheUsage = cacheUsage,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package top.jie65535.mirai.data
|
||||
|
||||
import com.aallam.openai.api.core.Usage
|
||||
import net.mamoe.mirai.event.events.GroupMessageEvent
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import top.jie65535.mirai.llm.ModelService
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class ModelUsageAttribution(
|
||||
val botId: Long = 0,
|
||||
val userId: Long = 0,
|
||||
val userNickname: String = "",
|
||||
val groupId: Long? = null,
|
||||
val groupName: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun from(event: MessageEvent): ModelUsageAttribution {
|
||||
val group = (event as? GroupMessageEvent)?.group
|
||||
return ModelUsageAttribution(
|
||||
botId = event.bot.id,
|
||||
userId = event.sender.id,
|
||||
userNickname = event.senderName,
|
||||
groupId = group?.id,
|
||||
groupName = group?.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ModelUsageRecorder {
|
||||
fun recordTokens(
|
||||
event: MessageEvent,
|
||||
endpointLabel: String,
|
||||
modelAlias: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
usageKind: String,
|
||||
usage: Usage?,
|
||||
cacheUsage: ModelService.CacheUsage? = null,
|
||||
) {
|
||||
usage ?: return
|
||||
val promptTokens = usage.promptTokens ?: 0
|
||||
val completionTokens = usage.completionTokens ?: 0
|
||||
recordTokenValues(
|
||||
attribution = ModelUsageAttribution.from(event),
|
||||
endpointLabel = endpointLabel,
|
||||
modelAlias = modelAlias,
|
||||
provider = provider,
|
||||
model = model,
|
||||
usageKind = usageKind,
|
||||
promptTokens = promptTokens.toLong(),
|
||||
completionTokens = completionTokens.toLong(),
|
||||
totalTokens = (usage.totalTokens ?: (promptTokens + completionTokens)).toLong(),
|
||||
cachedTokens = (cacheUsage?.hitTokens ?: 0).toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
fun recordTokenValues(
|
||||
attribution: ModelUsageAttribution,
|
||||
endpointLabel: String,
|
||||
modelAlias: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
usageKind: String,
|
||||
promptTokens: Long,
|
||||
completionTokens: Long,
|
||||
totalTokens: Long = promptTokens + completionTokens,
|
||||
cachedTokens: Long = 0,
|
||||
) {
|
||||
record(
|
||||
attribution = attribution,
|
||||
endpointLabel = endpointLabel,
|
||||
modelAlias = modelAlias,
|
||||
provider = provider,
|
||||
model = model,
|
||||
usageKind = usageKind,
|
||||
unit = "tokens",
|
||||
inputUnits = promptTokens,
|
||||
outputUnits = completionTokens,
|
||||
totalUnits = totalTokens,
|
||||
promptTokens = promptTokens,
|
||||
completionTokens = completionTokens,
|
||||
totalTokens = totalTokens,
|
||||
cachedTokens = cachedTokens,
|
||||
)
|
||||
}
|
||||
|
||||
fun recordUnits(
|
||||
event: MessageEvent,
|
||||
endpointLabel: String,
|
||||
modelAlias: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
usageKind: String,
|
||||
unit: String,
|
||||
inputUnits: Long = 0,
|
||||
outputUnits: Long = 0,
|
||||
totalUnits: Long = inputUnits + outputUnits,
|
||||
) {
|
||||
record(
|
||||
attribution = ModelUsageAttribution.from(event),
|
||||
endpointLabel = endpointLabel,
|
||||
modelAlias = modelAlias,
|
||||
provider = provider,
|
||||
model = model,
|
||||
usageKind = usageKind,
|
||||
unit = unit,
|
||||
inputUnits = inputUnits,
|
||||
outputUnits = outputUnits,
|
||||
totalUnits = totalUnits,
|
||||
)
|
||||
}
|
||||
|
||||
private fun record(
|
||||
attribution: ModelUsageAttribution,
|
||||
endpointLabel: String,
|
||||
modelAlias: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
usageKind: String,
|
||||
unit: String,
|
||||
inputUnits: Long,
|
||||
outputUnits: Long,
|
||||
totalUnits: Long,
|
||||
promptTokens: Long = 0,
|
||||
completionTokens: Long = 0,
|
||||
totalTokens: Long = 0,
|
||||
cachedTokens: Long = 0,
|
||||
) {
|
||||
TokenUsageStore.recordUsage(
|
||||
ModelUsageEvent(
|
||||
timestamp = OffsetDateTime.now().toEpochSecond(),
|
||||
botId = attribution.botId,
|
||||
userId = attribution.userId,
|
||||
userNickname = attribution.userNickname,
|
||||
groupId = attribution.groupId,
|
||||
groupName = attribution.groupName,
|
||||
endpointLabel = endpointLabel,
|
||||
modelAlias = modelAlias,
|
||||
provider = provider,
|
||||
model = model,
|
||||
usageKind = usageKind,
|
||||
unit = unit,
|
||||
inputUnits = inputUnits.coerceAtLeast(0),
|
||||
outputUnits = outputUnits.coerceAtLeast(0),
|
||||
totalUnits = totalUnits.coerceAtLeast(0),
|
||||
promptTokens = promptTokens.coerceAtLeast(0),
|
||||
completionTokens = completionTokens.coerceAtLeast(0),
|
||||
totalTokens = totalTokens.coerceAtLeast(0),
|
||||
cachedTokens = cachedTokens.coerceAtLeast(0),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ data class FavorabilityInfo(
|
||||
}
|
||||
|
||||
/**
|
||||
* Token使用日聚合记录。按 (date, userId, groupId) 维度合并。由 [TokenUsageStore] 持久化到独立 JSON 文件。
|
||||
* 旧版 Token 使用日聚合记录。仅用于将 token_usage.json 迁移到 [TokenUsageStore] 的 SQLite 明细表。
|
||||
* @param date 本地时区下的日期,格式 yyyy-MM-dd
|
||||
* @param userId QQ
|
||||
* @param userNickname 最近一次记录到的昵称
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package top.jie65535.mirai.data
|
||||
|
||||
data class ModelUsageEvent(
|
||||
val timestamp: Long,
|
||||
val botId: Long = 0,
|
||||
val userId: Long = 0,
|
||||
val userNickname: String = "",
|
||||
val groupId: Long? = null,
|
||||
val groupName: String? = null,
|
||||
val endpointLabel: String? = null,
|
||||
val modelAlias: String? = null,
|
||||
val provider: String? = null,
|
||||
val model: String? = null,
|
||||
val usageKind: String = "chat",
|
||||
val unit: String = "tokens",
|
||||
val inputUnits: Long = 0,
|
||||
val outputUnits: Long = 0,
|
||||
val totalUnits: Long = inputUnits + outputUnits,
|
||||
val promptTokens: Long = 0,
|
||||
val completionTokens: Long = 0,
|
||||
val totalTokens: Long = 0,
|
||||
val cachedTokens: Long = 0,
|
||||
)
|
||||
|
||||
data class TokenUsageRecord(
|
||||
val id: Long,
|
||||
val timestamp: Long,
|
||||
val date: String,
|
||||
val botId: Long?,
|
||||
val userId: Long,
|
||||
val userNickname: String,
|
||||
val groupId: Long?,
|
||||
val groupName: String?,
|
||||
val endpointLabel: String?,
|
||||
val provider: String?,
|
||||
val model: String?,
|
||||
val modelAlias: String? = null,
|
||||
val usageKind: String = "chat",
|
||||
val unit: String = "tokens",
|
||||
val inputUnits: Long = 0,
|
||||
val outputUnits: Long = 0,
|
||||
val totalUnits: Long = 0,
|
||||
val promptTokens: Long,
|
||||
val completionTokens: Long,
|
||||
val totalTokens: Long,
|
||||
val cachedTokens: Long,
|
||||
val callCount: Int,
|
||||
val detailed: Boolean,
|
||||
)
|
||||
|
||||
data class TokenUsageRanking(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val totalTokens: Long,
|
||||
)
|
||||
|
||||
data class TokenUsageModelTotal(
|
||||
val provider: String,
|
||||
val model: String,
|
||||
val totalTokens: Long,
|
||||
val callCount: Int,
|
||||
)
|
||||
|
||||
data class TokenUsageBreakdown(
|
||||
val provider: String,
|
||||
val model: String,
|
||||
val usageKind: String,
|
||||
val unit: String,
|
||||
val inputUnits: Long,
|
||||
val outputUnits: Long,
|
||||
val totalUnits: Long,
|
||||
val callCount: Int,
|
||||
)
|
||||
|
||||
data class ModelUsageUserTotal(
|
||||
val userId: Long,
|
||||
val name: String,
|
||||
val usageKind: String,
|
||||
val unit: String,
|
||||
val totalUnits: Long,
|
||||
val callCount: Int,
|
||||
)
|
||||
|
||||
data class ModelUsageDailyTotal(
|
||||
val date: String,
|
||||
val usageKind: String,
|
||||
val unit: String,
|
||||
val totalUnits: Long,
|
||||
val callCount: Int,
|
||||
)
|
||||
|
||||
data class TokenUsageDailyTotal(
|
||||
val date: String,
|
||||
val totalTokens: Long,
|
||||
)
|
||||
|
||||
data class TokenUsageSummary(
|
||||
val promptTokens: Long,
|
||||
val completionTokens: Long,
|
||||
val totalTokens: Long,
|
||||
val cachedTokens: Long,
|
||||
val callCount: Int,
|
||||
val activeUsers: Int,
|
||||
val todayTotal: Long,
|
||||
val daily: List<TokenUsageDailyTotal>,
|
||||
val topUsers: List<TokenUsageRanking>,
|
||||
val topGroups: List<TokenUsageRanking>,
|
||||
val models: List<TokenUsageModelTotal>,
|
||||
val allCallCount: Int = callCount,
|
||||
val allActiveUsers: Int = activeUsers,
|
||||
val breakdown: List<TokenUsageBreakdown> = emptyList(),
|
||||
val userUsage: List<ModelUsageUserTotal> = emptyList(),
|
||||
val usageDaily: List<ModelUsageDailyTotal> = emptyList(),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.config.ModelConfig
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -25,16 +26,29 @@ object LargeLanguageModels {
|
||||
val temperature: Double?,
|
||||
/** 唯一标识,用于健康状态跟踪与日志 */
|
||||
val label: String,
|
||||
val alias: String = "",
|
||||
val provider: String = "",
|
||||
)
|
||||
|
||||
data class ProfileEndpoint(
|
||||
val service: ModelService,
|
||||
val model: String,
|
||||
val alias: String = "",
|
||||
val provider: String = "",
|
||||
)
|
||||
|
||||
data class WebSummaryEndpoint(
|
||||
val service: ModelService,
|
||||
val model: String,
|
||||
val alias: String = "",
|
||||
val provider: String = "",
|
||||
)
|
||||
|
||||
data class AuxiliaryEndpoint(
|
||||
val service: ModelService,
|
||||
val model: String,
|
||||
val alias: String = "",
|
||||
val provider: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -52,12 +66,12 @@ object LargeLanguageModels {
|
||||
/**
|
||||
* 推理模型
|
||||
*/
|
||||
var reasoning: ModelService? = null
|
||||
var reasoning: AuxiliaryEndpoint? = null
|
||||
|
||||
/**
|
||||
* 视觉模型
|
||||
*/
|
||||
var visual: ModelService? = null
|
||||
var visual: AuxiliaryEndpoint? = null
|
||||
|
||||
/** 历史用户画像分析模型。 */
|
||||
var profile: ProfileEndpoint? = null
|
||||
@@ -116,28 +130,68 @@ object LargeLanguageModels {
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
(ModelCatalog.validationIssues() + ModelCatalog.roleValidationIssues())
|
||||
.distinct()
|
||||
.forEach(JChatGPT.logger::warning)
|
||||
val timeout = PluginConfig.timeout.milliseconds
|
||||
val firstChunkTimeout = PluginConfig.firstChunkTimeout.milliseconds
|
||||
|
||||
// 初始化聊天接入点(主 + 备用),并重置健康状态
|
||||
cooldownUntil.clear()
|
||||
val endpoints = mutableListOf<ChatEndpoint>()
|
||||
if (PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) {
|
||||
endpoints.add(
|
||||
ChatEndpoint(
|
||||
service = ModelService(
|
||||
baseUrl = PluginConfig.openAiApi,
|
||||
token = PluginConfig.openAiToken,
|
||||
timeout = timeout,
|
||||
firstChunkTimeout = firstChunkTimeout,
|
||||
extraBody = parseExtraBody(PluginConfig.chatModelExtraBody)
|
||||
),
|
||||
model = PluginConfig.chatModel,
|
||||
val primaryAlias = PluginConfig.chatModelAlias.trim()
|
||||
if (primaryAlias.isNotEmpty()) {
|
||||
resolveOpenAi(primaryAlias)?.let { definition ->
|
||||
endpoints += ChatEndpoint(
|
||||
service = modelService(definition, timeout, firstChunkTimeout),
|
||||
model = definition.model,
|
||||
temperature = PluginConfig.chatTemperature,
|
||||
label = "primary",
|
||||
label = "primary:$primaryAlias",
|
||||
alias = primaryAlias,
|
||||
provider = definition.provider,
|
||||
)
|
||||
}
|
||||
}
|
||||
var legacyPrimaryUsed = false
|
||||
if (endpoints.isEmpty() && PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) {
|
||||
endpoints += ChatEndpoint(
|
||||
service = ModelService(
|
||||
baseUrl = ModelCatalog.normalizeOpenAiApi(PluginConfig.openAiApi),
|
||||
token = PluginConfig.openAiToken,
|
||||
timeout = timeout,
|
||||
firstChunkTimeout = firstChunkTimeout,
|
||||
extraBody = parseExtraBody(PluginConfig.chatModelExtraBody)
|
||||
),
|
||||
model = PluginConfig.chatModel,
|
||||
temperature = PluginConfig.chatTemperature,
|
||||
label = "primary",
|
||||
alias = "legacy-primary",
|
||||
provider = providerName(PluginConfig.openAiApi),
|
||||
)
|
||||
legacyPrimaryUsed = true
|
||||
}
|
||||
|
||||
PluginConfig.chatFallbackModelAliases.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
.forEach { alias ->
|
||||
resolveOpenAi(alias)?.let { definition ->
|
||||
val label = if (endpoints.isEmpty()) {
|
||||
"primary:$alias"
|
||||
} else {
|
||||
"fallback${endpoints.size - 1}:$alias"
|
||||
}
|
||||
endpoints += ChatEndpoint(
|
||||
service = modelService(definition, timeout, firstChunkTimeout),
|
||||
model = definition.model,
|
||||
temperature = PluginConfig.chatTemperature,
|
||||
label = label,
|
||||
alias = alias,
|
||||
provider = definition.provider,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (legacyPrimaryUsed) {
|
||||
// 备用接入点:留空字段继承主接入点配置
|
||||
PluginConfig.chatFallbacks.forEachIndexed { i, fb ->
|
||||
val api = fb.api.ifBlank { PluginConfig.openAiApi }
|
||||
@@ -145,19 +199,19 @@ object LargeLanguageModels {
|
||||
val model = fb.model.ifBlank { PluginConfig.chatModel }
|
||||
val extraBody = fb.extraBody.ifBlank { PluginConfig.chatModelExtraBody }
|
||||
if (api.isNotBlank() && token.isNotBlank()) {
|
||||
endpoints.add(
|
||||
ChatEndpoint(
|
||||
service = ModelService(
|
||||
baseUrl = api,
|
||||
token = token,
|
||||
timeout = timeout,
|
||||
firstChunkTimeout = firstChunkTimeout,
|
||||
extraBody = parseExtraBody(extraBody)
|
||||
),
|
||||
model = model,
|
||||
temperature = PluginConfig.chatTemperature,
|
||||
label = "fallback$i:$model",
|
||||
)
|
||||
endpoints += ChatEndpoint(
|
||||
service = ModelService(
|
||||
baseUrl = ModelCatalog.normalizeOpenAiApi(api),
|
||||
token = token,
|
||||
timeout = timeout,
|
||||
firstChunkTimeout = firstChunkTimeout,
|
||||
extraBody = parseExtraBody(extraBody)
|
||||
),
|
||||
model = model,
|
||||
temperature = PluginConfig.chatTemperature,
|
||||
label = "fallback${endpoints.size - 1}:legacy-$i:$model",
|
||||
alias = "legacy-fallback$i",
|
||||
provider = providerName(api),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -166,69 +220,144 @@ object LargeLanguageModels {
|
||||
|
||||
profile = null
|
||||
if (PluginConfig.profileEnabled) {
|
||||
val api = PluginConfig.profileModelApi.ifBlank { PluginConfig.openAiApi }
|
||||
val token = PluginConfig.profileModelToken.ifBlank { PluginConfig.openAiToken }
|
||||
val model = PluginConfig.profileModel.ifBlank { PluginConfig.chatModel }
|
||||
val extraBody = PluginConfig.profileModelExtraBody.ifBlank { PluginConfig.chatModelExtraBody }
|
||||
if (api.isNotBlank() && token.isNotBlank() && model.isNotBlank()) {
|
||||
val profileAlias = PluginConfig.profileModelAlias.ifBlank { PluginConfig.chatModelAlias }
|
||||
val definition = profileAlias.trim().takeIf(String::isNotEmpty)?.let(::resolveOpenAi)
|
||||
if (definition != null) {
|
||||
val profileFirstChunk = PluginConfig.profileFirstChunkTimeout.milliseconds
|
||||
profile = ProfileEndpoint(
|
||||
service = ModelService(
|
||||
baseUrl = api,
|
||||
token = token,
|
||||
service = modelService(
|
||||
definition,
|
||||
timeout = maxOf(timeout, profileFirstChunk),
|
||||
firstChunkTimeout = profileFirstChunk,
|
||||
extraBody = parseExtraBody(extraBody),
|
||||
maxConcurrentRequests = PluginConfig.profileMaxConcurrentRequests,
|
||||
),
|
||||
model = model,
|
||||
)
|
||||
),
|
||||
model = definition.model,
|
||||
alias = definition.alias,
|
||||
provider = definition.provider,
|
||||
)
|
||||
} else {
|
||||
val api = PluginConfig.profileModelApi.ifBlank { PluginConfig.openAiApi }
|
||||
val token = PluginConfig.profileModelToken.ifBlank { PluginConfig.openAiToken }
|
||||
val model = PluginConfig.profileModel.ifBlank { PluginConfig.chatModel }
|
||||
val extraBody = PluginConfig.profileModelExtraBody.ifBlank { PluginConfig.chatModelExtraBody }
|
||||
if (api.isNotBlank() && token.isNotBlank() && model.isNotBlank()) {
|
||||
val profileFirstChunk = PluginConfig.profileFirstChunkTimeout.milliseconds
|
||||
profile = ProfileEndpoint(
|
||||
service = ModelService(
|
||||
baseUrl = ModelCatalog.normalizeOpenAiApi(api),
|
||||
token = token,
|
||||
timeout = maxOf(timeout, profileFirstChunk),
|
||||
firstChunkTimeout = profileFirstChunk,
|
||||
extraBody = parseExtraBody(extraBody),
|
||||
maxConcurrentRequests = PluginConfig.profileMaxConcurrentRequests,
|
||||
),
|
||||
model = model,
|
||||
alias = "legacy-profile",
|
||||
provider = providerName(api),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webSummary = null
|
||||
if (PluginConfig.webSummaryModelApi.isNotBlank() &&
|
||||
val webSummaryDefinition = PluginConfig.webSummaryModelAlias.trim().takeIf(String::isNotEmpty)
|
||||
?.let(::resolveOpenAi)
|
||||
if (webSummaryDefinition != null) {
|
||||
val webSummaryFirstChunk = PluginConfig.webSummaryFirstChunkTimeout.milliseconds
|
||||
webSummary = WebSummaryEndpoint(
|
||||
service = modelService(
|
||||
webSummaryDefinition,
|
||||
timeout = maxOf(timeout, webSummaryFirstChunk),
|
||||
firstChunkTimeout = webSummaryFirstChunk,
|
||||
),
|
||||
model = webSummaryDefinition.model,
|
||||
alias = webSummaryDefinition.alias,
|
||||
provider = webSummaryDefinition.provider,
|
||||
)
|
||||
} else if (PluginConfig.webSummaryModelApi.isNotBlank() &&
|
||||
PluginConfig.webSummaryModelToken.isNotBlank() &&
|
||||
PluginConfig.webSummaryModel.isNotBlank()
|
||||
) {
|
||||
val webSummaryFirstChunk = PluginConfig.webSummaryFirstChunkTimeout.milliseconds
|
||||
webSummary = WebSummaryEndpoint(
|
||||
service = ModelService(
|
||||
baseUrl = PluginConfig.webSummaryModelApi,
|
||||
baseUrl = ModelCatalog.normalizeOpenAiApi(PluginConfig.webSummaryModelApi),
|
||||
token = PluginConfig.webSummaryModelToken,
|
||||
timeout = maxOf(timeout, webSummaryFirstChunk),
|
||||
firstChunkTimeout = webSummaryFirstChunk,
|
||||
extraBody = parseExtraBody(PluginConfig.webSummaryModelExtraBody),
|
||||
),
|
||||
model = PluginConfig.webSummaryModel,
|
||||
alias = "legacy-web-summary",
|
||||
provider = providerName(PluginConfig.webSummaryModelApi),
|
||||
)
|
||||
}
|
||||
|
||||
// 初始化推理模型
|
||||
if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) {
|
||||
reasoning = null
|
||||
val reasoningDefinition = PluginConfig.reasoningModelAlias.trim().takeIf(String::isNotEmpty)
|
||||
?.let(::resolveOpenAi)
|
||||
if (reasoningDefinition != null) {
|
||||
val reasoningFirstChunk = PluginConfig.reasoningFirstChunkTimeout.milliseconds
|
||||
reasoning = AuxiliaryEndpoint(
|
||||
service = modelService(
|
||||
reasoningDefinition,
|
||||
timeout = maxOf(timeout, reasoningFirstChunk),
|
||||
firstChunkTimeout = reasoningFirstChunk,
|
||||
),
|
||||
model = reasoningDefinition.model,
|
||||
alias = reasoningDefinition.alias,
|
||||
provider = reasoningDefinition.provider,
|
||||
)
|
||||
} else if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) {
|
||||
// 推理模型出首块前常有思考预热,比对话慢,使用单独放宽的首块超时;
|
||||
// socket 超时(两次读间隔,等首块时也归它管)不能小于首块预算,否则首块超时形同虚设
|
||||
val reasoningFirstChunk = PluginConfig.reasoningFirstChunkTimeout.milliseconds
|
||||
reasoning = ModelService(
|
||||
baseUrl = PluginConfig.reasoningModelApi,
|
||||
token = PluginConfig.reasoningModelToken,
|
||||
timeout = maxOf(timeout, reasoningFirstChunk),
|
||||
firstChunkTimeout = reasoningFirstChunk,
|
||||
extraBody = parseExtraBody(PluginConfig.reasoningModelExtraBody)
|
||||
reasoning = AuxiliaryEndpoint(
|
||||
service = ModelService(
|
||||
baseUrl = ModelCatalog.normalizeOpenAiApi(PluginConfig.reasoningModelApi),
|
||||
token = PluginConfig.reasoningModelToken,
|
||||
timeout = maxOf(timeout, reasoningFirstChunk),
|
||||
firstChunkTimeout = reasoningFirstChunk,
|
||||
extraBody = parseExtraBody(PluginConfig.reasoningModelExtraBody)
|
||||
),
|
||||
model = PluginConfig.reasoningModel,
|
||||
alias = "legacy-reasoning",
|
||||
provider = providerName(PluginConfig.reasoningModelApi),
|
||||
)
|
||||
}
|
||||
|
||||
// 初始化视觉模型
|
||||
if (PluginConfig.visualModelApi.isNotBlank() && PluginConfig.visualModelToken.isNotBlank()) {
|
||||
visual = null
|
||||
val visualDefinition = PluginConfig.visualModelAlias.trim().takeIf(String::isNotEmpty)
|
||||
?.let(::resolveOpenAi)
|
||||
if (visualDefinition != null) {
|
||||
val visualFirstChunk = PluginConfig.visualFirstChunkTimeout.milliseconds
|
||||
visual = AuxiliaryEndpoint(
|
||||
service = modelService(
|
||||
visualDefinition,
|
||||
timeout = maxOf(timeout, visualFirstChunk),
|
||||
firstChunkTimeout = visualFirstChunk,
|
||||
),
|
||||
model = visualDefinition.model,
|
||||
alias = visualDefinition.alias,
|
||||
provider = visualDefinition.provider,
|
||||
)
|
||||
} else if (PluginConfig.visualModelApi.isNotBlank() && PluginConfig.visualModelToken.isNotBlank()) {
|
||||
// 视觉模型需服务端先下载图片再出首块,比对话天然慢,使用单独放宽的首块超时;
|
||||
// socket 超时(两次读间隔,等首块时也归它管)不能小于首块预算,否则首块超时形同虚设
|
||||
val visualFirstChunk = PluginConfig.visualFirstChunkTimeout.milliseconds
|
||||
visual = ModelService(
|
||||
baseUrl = PluginConfig.visualModelApi,
|
||||
token = PluginConfig.visualModelToken,
|
||||
timeout = maxOf(timeout, visualFirstChunk),
|
||||
firstChunkTimeout = visualFirstChunk,
|
||||
extraBody = parseExtraBody(PluginConfig.visualModelExtraBody)
|
||||
visual = AuxiliaryEndpoint(
|
||||
service = ModelService(
|
||||
baseUrl = ModelCatalog.normalizeOpenAiApi(PluginConfig.visualModelApi),
|
||||
token = PluginConfig.visualModelToken,
|
||||
timeout = maxOf(timeout, visualFirstChunk),
|
||||
firstChunkTimeout = visualFirstChunk,
|
||||
extraBody = parseExtraBody(PluginConfig.visualModelExtraBody)
|
||||
),
|
||||
model = PluginConfig.visualModel,
|
||||
alias = "legacy-visual",
|
||||
provider = providerName(PluginConfig.visualModelApi),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -249,4 +378,38 @@ object LargeLanguageModels {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveOpenAi(alias: String): ResolvedModelDefinition? {
|
||||
val definition = ModelCatalog.resolve(alias)
|
||||
if (definition == null) {
|
||||
JChatGPT.logger.warning("Models.yml 中不存在模型别名:$alias")
|
||||
return null
|
||||
}
|
||||
if (definition.providerType !in setOf("openai", "openai-compatible", "openai_compatible")) {
|
||||
JChatGPT.logger.warning("模型别名 $alias 的提供商类型 ${definition.providerType} 不能用于 OpenAI 兼容客户端")
|
||||
return null
|
||||
}
|
||||
if (definition.api.isBlank() || definition.token.isBlank() || definition.model.isBlank()) {
|
||||
JChatGPT.logger.warning("模型别名 $alias 的 provider/api/token/model 配置不完整")
|
||||
return null
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
private fun modelService(
|
||||
definition: ResolvedModelDefinition,
|
||||
timeout: kotlin.time.Duration,
|
||||
firstChunkTimeout: kotlin.time.Duration,
|
||||
maxConcurrentRequests: Int? = null,
|
||||
): ModelService = ModelService(
|
||||
baseUrl = ModelCatalog.normalizeOpenAiApi(definition.api),
|
||||
token = definition.token,
|
||||
timeout = timeout,
|
||||
firstChunkTimeout = firstChunkTimeout,
|
||||
extraBody = parseExtraBody(definition.extraBody),
|
||||
maxConcurrentRequests = maxConcurrentRequests,
|
||||
)
|
||||
|
||||
private fun providerName(api: String): String =
|
||||
runCatching { java.net.URI.create(api.trim()).host.orEmpty() }.getOrDefault("")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package top.jie65535.mirai.llm
|
||||
|
||||
import top.jie65535.mirai.config.ModelConfig
|
||||
import top.jie65535.mirai.config.ModelDefinition
|
||||
import top.jie65535.mirai.config.ModelProviderDefinition
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
|
||||
data class ResolvedModelDefinition(
|
||||
val alias: String,
|
||||
val provider: String,
|
||||
val providerType: String,
|
||||
val api: String,
|
||||
val token: String,
|
||||
val model: String,
|
||||
val extraBody: String,
|
||||
)
|
||||
|
||||
/** Resolves shared model aliases and keeps legacy Config.yml fallback logic in one place. */
|
||||
object ModelCatalog {
|
||||
private const val DEFAULT_DASHSCOPE_IMAGE_API =
|
||||
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
|
||||
private val openAiProviderTypes = setOf(
|
||||
"openai",
|
||||
"openai-compatible",
|
||||
"openai_compatible",
|
||||
)
|
||||
private val supportedProviderTypes = openAiProviderTypes + setOf(
|
||||
"dashscope",
|
||||
)
|
||||
|
||||
fun resolve(alias: String): ResolvedModelDefinition? =
|
||||
resolve(alias, ModelConfig.providers, ModelConfig.models)
|
||||
|
||||
internal fun resolve(
|
||||
alias: String,
|
||||
providers: List<ModelProviderDefinition>,
|
||||
models: List<ModelDefinition>,
|
||||
): ResolvedModelDefinition? {
|
||||
val normalizedAlias = alias.trim()
|
||||
if (normalizedAlias.isEmpty()) return null
|
||||
val model = models.filter { it.name.trim() == normalizedAlias }.singleOrNull() ?: return null
|
||||
val provider = providers.filter { it.name.trim() == model.provider.trim() }.singleOrNull()
|
||||
?: return null
|
||||
return model.resolve(provider, normalizedAlias)
|
||||
}
|
||||
|
||||
fun validationIssues(): List<String> = validationIssues(ModelConfig.providers, ModelConfig.models)
|
||||
|
||||
fun roleValidationIssues(): List<String> {
|
||||
val bindings = buildList {
|
||||
add(Triple("主聊天", PluginConfig.chatModelAlias, openAiProviderTypes))
|
||||
PluginConfig.chatFallbackModelAliases.forEachIndexed { index, alias ->
|
||||
add(Triple("聊天备用 ${index + 1}", alias, openAiProviderTypes))
|
||||
}
|
||||
add(
|
||||
Triple(
|
||||
"画像",
|
||||
PluginConfig.profileModelAlias.ifBlank { PluginConfig.chatModelAlias },
|
||||
openAiProviderTypes,
|
||||
)
|
||||
)
|
||||
add(Triple("推理", PluginConfig.reasoningModelAlias, openAiProviderTypes))
|
||||
add(Triple("视觉", PluginConfig.visualModelAlias, openAiProviderTypes))
|
||||
add(Triple("网页摘要", PluginConfig.webSummaryModelAlias, openAiProviderTypes))
|
||||
add(Triple("图像", PluginConfig.imageModelAlias, setOf("dashscope")))
|
||||
add(Triple("TTS", PluginConfig.ttsModelAlias, setOf("dashscope")))
|
||||
}
|
||||
return bindings.mapNotNull { (role, alias, allowedTypes) ->
|
||||
bindingValidationIssue(
|
||||
role = role,
|
||||
alias = alias,
|
||||
allowedTypes = allowedTypes,
|
||||
providers = ModelConfig.providers,
|
||||
models = ModelConfig.models,
|
||||
)
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
internal fun bindingValidationIssue(
|
||||
role: String,
|
||||
alias: String,
|
||||
allowedTypes: Set<String>,
|
||||
providers: List<ModelProviderDefinition>,
|
||||
models: List<ModelDefinition>,
|
||||
): String? {
|
||||
val normalizedAlias = alias.trim()
|
||||
if (normalizedAlias.isEmpty()) return null
|
||||
val definition = resolve(normalizedAlias, providers, models)
|
||||
?: return "Models.yml 的 $role 角色引用了无效模型别名:$normalizedAlias"
|
||||
return if (definition.providerType !in allowedTypes) {
|
||||
"Models.yml 的 $role 角色不能使用 provider type ${definition.providerType}:$normalizedAlias"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun validationIssues(
|
||||
providers: List<ModelProviderDefinition>,
|
||||
models: List<ModelDefinition>,
|
||||
): List<String> = buildList {
|
||||
val providerNames = providers.map { it.name.trim() }
|
||||
providerNames.filter(String::isEmpty).forEach { add("Models.yml 存在空 provider 名称") }
|
||||
providerNames.groupingBy(String::toString).eachCount()
|
||||
.filterValues { it > 1 }
|
||||
.keys
|
||||
.forEach { add("Models.yml provider 名称重复:$it") }
|
||||
|
||||
val modelNames = models.map { it.name.trim() }
|
||||
modelNames.filter(String::isEmpty).forEach { add("Models.yml 存在空模型别名") }
|
||||
modelNames.groupingBy(String::toString).eachCount()
|
||||
.filterValues { it > 1 }
|
||||
.keys
|
||||
.forEach { add("Models.yml 模型别名重复:$it") }
|
||||
|
||||
providers.forEach { provider ->
|
||||
val name = provider.name.trim().ifBlank { "<empty>" }
|
||||
val type = provider.type.trim().lowercase().ifBlank { "openai" }
|
||||
if (type !in supportedProviderTypes) add("Models.yml provider $name 的 type 不受支持:$type")
|
||||
if (type != "dashscope" && provider.api.isBlank()) add("Models.yml provider $name 未配置 api")
|
||||
if (provider.token.isBlank()) add("Models.yml provider $name 未配置 token")
|
||||
}
|
||||
models.forEach { model ->
|
||||
val alias = model.name.trim().ifBlank { "<empty>" }
|
||||
val providerName = model.provider.trim()
|
||||
if (providerName.isEmpty() || providerNames.count { it == providerName } != 1) {
|
||||
add("Models.yml 模型 $alias 引用的 provider 无效:${providerName.ifBlank { "<empty>" }}")
|
||||
}
|
||||
if (model.model.isBlank()) add("Models.yml 模型 $alias 未配置实际模型名")
|
||||
}
|
||||
}.distinct()
|
||||
|
||||
fun resolveImage(): ResolvedModelDefinition? =
|
||||
resolveDashScope(PluginConfig.imageModelAlias)
|
||||
?: legacy(
|
||||
alias = "legacy-image",
|
||||
provider = "dashscope",
|
||||
providerType = "dashscope",
|
||||
api = DEFAULT_DASHSCOPE_IMAGE_API,
|
||||
token = PluginConfig.dashScopeApiKey,
|
||||
model = PluginConfig.imageModel,
|
||||
)
|
||||
|
||||
fun resolveTts(): ResolvedModelDefinition? =
|
||||
resolveDashScope(PluginConfig.ttsModelAlias)
|
||||
?: legacy(
|
||||
alias = "legacy-tts",
|
||||
provider = "dashscope",
|
||||
providerType = "dashscope",
|
||||
api = DEFAULT_DASHSCOPE_IMAGE_API,
|
||||
token = PluginConfig.dashScopeApiKey,
|
||||
model = PluginConfig.ttsModel,
|
||||
)
|
||||
|
||||
fun normalizeOpenAiApi(api: String): String = api.trim().trimEnd('/') + "/"
|
||||
|
||||
private fun resolveDashScope(alias: String): ResolvedModelDefinition? =
|
||||
resolve(alias)
|
||||
?.takeIf { it.providerType == "dashscope" && it.token.isNotBlank() && it.model.isNotBlank() }
|
||||
?.let { it.copy(api = it.api.ifBlank { DEFAULT_DASHSCOPE_IMAGE_API }) }
|
||||
|
||||
private fun ModelDefinition.resolve(
|
||||
provider: ModelProviderDefinition,
|
||||
alias: String,
|
||||
): ResolvedModelDefinition = ResolvedModelDefinition(
|
||||
alias = alias,
|
||||
provider = provider.name.trim(),
|
||||
providerType = provider.type.trim().lowercase().ifBlank { "openai" },
|
||||
api = provider.api.trim(),
|
||||
token = provider.token.trim(),
|
||||
model = model.trim(),
|
||||
extraBody = extraBody,
|
||||
)
|
||||
|
||||
private fun legacy(
|
||||
alias: String,
|
||||
provider: String,
|
||||
providerType: String,
|
||||
api: String,
|
||||
token: String,
|
||||
model: String,
|
||||
extraBody: String = "",
|
||||
): ResolvedModelDefinition? {
|
||||
if (api.isBlank() || token.isBlank() || model.isBlank()) return null
|
||||
return ResolvedModelDefinition(alias, provider, providerType, api, token, model.trim(), extraBody)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import com.aallam.openai.api.core.Usage
|
||||
import com.aallam.openai.api.model.ModelId
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import net.mamoe.mirai.message.data.MessageSourceKind
|
||||
import top.jie65535.mirai.data.ModelUsageAttribution
|
||||
import top.jie65535.mirai.data.ModelUsageRecorder
|
||||
import top.jie65535.mirai.llm.LargeLanguageModels
|
||||
import top.jie65535.mirai.llm.ModelService
|
||||
|
||||
@@ -86,6 +89,8 @@ class ProfileModelClient(
|
||||
),
|
||||
)
|
||||
)
|
||||
recordUsage(batch.usageAttribution(), completion)
|
||||
require(completion.content.isNotBlank()) { "模型流式响应没有文本内容" }
|
||||
val raw = completion.content.replace(THINK_REGEX, "").trim()
|
||||
val response = parseResponse(raw)
|
||||
return ProfileModelResult(
|
||||
@@ -125,6 +130,15 @@ class ProfileModelClient(
|
||||
),
|
||||
)
|
||||
)
|
||||
recordUsage(
|
||||
ModelUsageAttribution(
|
||||
botId = batch.botId,
|
||||
userId = 0,
|
||||
groupId = batch.groupId,
|
||||
),
|
||||
completion,
|
||||
)
|
||||
require(completion.content.isNotBlank()) { "模型流式响应没有文本内容" }
|
||||
val raw = completion.content.replace(THINK_REGEX, "").trim()
|
||||
return ConversationProfileModelResult(
|
||||
response = parseObject(raw),
|
||||
@@ -148,6 +162,13 @@ class ProfileModelClient(
|
||||
),
|
||||
)
|
||||
)
|
||||
recordUsage(
|
||||
ModelUsageAttribution(
|
||||
userId = profile.userId,
|
||||
),
|
||||
completion,
|
||||
)
|
||||
require(completion.content.isNotBlank()) { "模型流式响应没有文本内容" }
|
||||
val raw = completion.content.replace(THINK_REGEX, "").trim()
|
||||
return ProfileCompactionModelResult(
|
||||
response = json.decodeFromString(extractObject(raw)),
|
||||
@@ -164,10 +185,10 @@ class ProfileModelClient(
|
||||
chunk.choices.firstOrNull()?.delta?.content?.let(content::append)
|
||||
chunk.usage?.let { lastUsage = it }
|
||||
}
|
||||
require(content.isNotBlank()) { "模型流式响应没有文本内容" }
|
||||
return CompletedProfileResponse(
|
||||
content = content.toString(),
|
||||
usage = lastUsage.toProfileUsage(cacheUsage),
|
||||
usageAvailable = lastUsage != null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,6 +198,31 @@ class ProfileModelClient(
|
||||
cachedTokens = cacheUsage?.hitTokens ?: 0,
|
||||
)
|
||||
|
||||
private fun recordUsage(attribution: ModelUsageAttribution, completion: CompletedProfileResponse) {
|
||||
if (!completion.usageAvailable) return
|
||||
val usage = completion.usage
|
||||
ModelUsageRecorder.recordTokenValues(
|
||||
attribution = attribution,
|
||||
endpointLabel = "profile",
|
||||
modelAlias = endpoint.alias,
|
||||
provider = endpoint.provider,
|
||||
model = endpoint.model,
|
||||
usageKind = "profile",
|
||||
promptTokens = usage.promptTokens.toLong(),
|
||||
completionTokens = usage.completionTokens.toLong(),
|
||||
cachedTokens = usage.cachedTokens.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ProfileHistoryBatch.usageAttribution(): ModelUsageAttribution {
|
||||
val record = messages.firstOrNull()?.record
|
||||
return ModelUsageAttribution(
|
||||
botId = record?.botId ?: 0,
|
||||
userId = userId,
|
||||
groupId = record?.targetId?.takeIf { record.kind == MessageSourceKind.GROUP },
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseResponse(raw: String): ProfileModelResponse {
|
||||
return parseObject(raw)
|
||||
}
|
||||
@@ -206,5 +252,6 @@ class ProfileModelClient(
|
||||
private data class CompletedProfileResponse(
|
||||
val content: String,
|
||||
val usage: ProfileTokenUsage,
|
||||
val usageAvailable: Boolean,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,12 +17,15 @@ import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import top.jie65535.mirai.data.ModelUsageRecorder
|
||||
import top.jie65535.mirai.llm.ModelCatalog
|
||||
|
||||
class ImageAgent : BaseAgent(
|
||||
tool = Tool.function(
|
||||
@@ -59,13 +62,15 @@ class ImageAgent : BaseAgent(
|
||||
}
|
||||
|
||||
override val isEnabled: Boolean
|
||||
get() = PluginConfig.dashScopeApiKey.isNotEmpty()
|
||||
get() = ModelCatalog.resolveImage() != null
|
||||
|
||||
override val loadingMessage: String
|
||||
get() = "作图中..."
|
||||
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
val modelDefinition = ModelCatalog.resolveImage()
|
||||
?: return "未配置图像模型,无法生成图片。"
|
||||
val prompt = args.getValue("prompt").jsonPrimitive.content
|
||||
val imageIndices = args["image_indices"]?.jsonArray
|
||||
?.map { it.jsonPrimitive.int }
|
||||
@@ -76,11 +81,11 @@ class ImageAgent : BaseAgent(
|
||||
?: throw IllegalArgumentException("图片编号[$imageIndex]不存在或已失效")
|
||||
}
|
||||
|
||||
val response = httpClient.post(API_URL) {
|
||||
val response = httpClient.post(modelDefinition.api.ifBlank { API_URL }) {
|
||||
contentType(ContentType("application", "json"))
|
||||
header("Authorization", "Bearer " + PluginConfig.dashScopeApiKey)
|
||||
header("Authorization", "Bearer " + modelDefinition.token)
|
||||
setBody(buildJsonObject {
|
||||
put("model", PluginConfig.imageModel)
|
||||
put("model", modelDefinition.model)
|
||||
putJsonObject("input") {
|
||||
putJsonArray("messages") {
|
||||
addJsonObject {
|
||||
@@ -115,6 +120,21 @@ class ImageAgent : BaseAgent(
|
||||
.getValue("message").jsonObject
|
||||
.getValue("content").jsonArray[0].jsonObject
|
||||
.getValue("image").jsonPrimitive.content
|
||||
val outputImages = (responseObject["usage"] as? JsonObject)
|
||||
?.get("image_count")?.jsonPrimitive?.longOrNull
|
||||
?.coerceAtLeast(1)
|
||||
?: 1L
|
||||
ModelUsageRecorder.recordUnits(
|
||||
event = event,
|
||||
endpointLabel = "image",
|
||||
modelAlias = modelDefinition.alias,
|
||||
provider = modelDefinition.provider,
|
||||
model = modelDefinition.model,
|
||||
usageKind = "image",
|
||||
unit = "images",
|
||||
inputUnits = imageUrls.size.toLong(),
|
||||
outputUnits = outputImages,
|
||||
)
|
||||
"图片已生成,发送时请务必包含完整的url和查询参数,因为下载地址存在鉴权:"
|
||||
} catch (e: Throwable) {
|
||||
JChatGPT.logger.error("图像生成结果解析异常", e)
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import io.ktor.client.plugins.timeout
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.isSuccess
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import net.mamoe.mirai.event.events.GroupMessageEvent
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import top.jie65535.mirai.config.ModelConfig
|
||||
import top.jie65535.mirai.config.ModelDefinition
|
||||
import top.jie65535.mirai.config.ModelProviderDefinition
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import top.jie65535.mirai.data.TokenUsageRecord
|
||||
import top.jie65535.mirai.data.TokenUsageStore
|
||||
import top.jie65535.mirai.data.TokenUsageSummary
|
||||
import top.jie65535.mirai.llm.ModelCatalog
|
||||
import java.net.URI
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
class QueryTokenUsageAgent : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "queryTokenUsage",
|
||||
description = "查询当前会话的模型用量或提供商余额。",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("operation") {
|
||||
put("type", "string")
|
||||
putJsonArray("enum") {
|
||||
add("summary")
|
||||
add("details")
|
||||
add("balance")
|
||||
add("overview")
|
||||
}
|
||||
put("description", "summary用量,details明细,balance余额,overview为用量加余额")
|
||||
}
|
||||
putJsonObject("days") {
|
||||
put("type", "integer")
|
||||
put("description", "统计最近多少天,包含今天,默认7,范围1到3650")
|
||||
}
|
||||
putJsonObject("limit") {
|
||||
put("type", "integer")
|
||||
put("description", "排名或最近明细条数,默认20,最多100")
|
||||
}
|
||||
putJsonObject("userId") {
|
||||
put("type", "integer")
|
||||
put("description", "群聊中可选,仅统计当前群内指定用户QQ号;私聊中忽略此参数并固定为当前私聊对象")
|
||||
}
|
||||
putJsonObject("usageType") {
|
||||
put("type", "string")
|
||||
putJsonArray("enum") {
|
||||
add("chat")
|
||||
add("profile")
|
||||
add("reasoning")
|
||||
add("visual")
|
||||
add("web_summary")
|
||||
add("image")
|
||||
add("tts")
|
||||
}
|
||||
put("description", "可选,按模型用途筛选")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
private const val MAX_DAYS = 3650
|
||||
private const val MAX_DETAILS = 100
|
||||
private const val DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance"
|
||||
private val OPERATIONS = setOf("summary", "details", "balance", "overview")
|
||||
private val USAGE_TYPES = setOf("chat", "profile", "reasoning", "visual", "web_summary", "image", "tts")
|
||||
private val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
|
||||
|
||||
internal data class QueryScope(
|
||||
val botId: Long,
|
||||
val userId: Long?,
|
||||
val groupId: Long?,
|
||||
val privateOnly: Boolean,
|
||||
)
|
||||
|
||||
internal data class BalanceAccount(
|
||||
val name: String,
|
||||
val api: String,
|
||||
val token: String,
|
||||
)
|
||||
|
||||
internal fun queryScope(
|
||||
botId: Long,
|
||||
senderId: Long,
|
||||
currentGroupId: Long?,
|
||||
requestedUserId: Long?,
|
||||
): QueryScope = if (currentGroupId == null) {
|
||||
QueryScope(botId, senderId, null, privateOnly = true)
|
||||
} else {
|
||||
QueryScope(botId, requestedUserId, currentGroupId, privateOnly = false)
|
||||
}
|
||||
|
||||
internal fun isDeepSeekApi(api: String): Boolean {
|
||||
val host = runCatching { URI.create(api.trim()).host?.lowercase() }.getOrNull() ?: return false
|
||||
return host == "api.deepseek.com" || host.endsWith(".deepseek.com")
|
||||
}
|
||||
|
||||
internal fun parseDeepSeekBalance(body: String): JsonObject {
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
return buildJsonObject {
|
||||
put("available", root["is_available"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() ?: false)
|
||||
putJsonArray("balances") {
|
||||
root["balance_infos"]?.jsonArray?.forEach { element ->
|
||||
val info = element.jsonObject
|
||||
addJsonObject {
|
||||
put("currency", info["currency"]?.jsonPrimitive?.contentOrNull.orEmpty())
|
||||
put("total", info["total_balance"]?.jsonPrimitive?.contentOrNull.orEmpty())
|
||||
put("granted", info["granted_balance"]?.jsonPrimitive?.contentOrNull.orEmpty())
|
||||
put("toppedUp", info["topped_up_balance"]?.jsonPrimitive?.contentOrNull.orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun collectDeepSeekBalanceAccounts(
|
||||
providers: List<ModelProviderDefinition>,
|
||||
models: List<ModelDefinition>,
|
||||
legacyAccounts: List<BalanceAccount> = emptyList(),
|
||||
): List<BalanceAccount> {
|
||||
val referencedProviders = models.mapTo(HashSet()) { it.provider.trim() }
|
||||
val accounts = providers.asSequence()
|
||||
.filter { it.name.trim() in referencedProviders }
|
||||
.filter { it.token.isNotBlank() && isDeepSeekApi(it.api) }
|
||||
.map { BalanceAccount(it.name.trim().ifBlank { "deepseek" }, it.api.trim(), it.token.trim()) }
|
||||
.toMutableList()
|
||||
accounts += legacyAccounts.filter { it.token.isNotBlank() && isDeepSeekApi(it.api) }
|
||||
.map { it.copy(api = it.api.trim(), token = it.token.trim()) }
|
||||
return accounts.distinctBy { it.token }
|
||||
}
|
||||
}
|
||||
|
||||
override val isEnabled: Boolean
|
||||
get() = TokenUsageStore.isAvailable ||
|
||||
configuredDeepSeekAccounts().isNotEmpty()
|
||||
|
||||
override val loadingMessage: String
|
||||
get() = "查询模型用量中..."
|
||||
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
val operation = args?.get("operation")?.jsonPrimitive?.contentOrNull?.lowercase() ?: "summary"
|
||||
require(operation in OPERATIONS) { "不支持的模型用量查询操作:$operation" }
|
||||
val days = (args?.get("days")?.jsonPrimitive?.intOrNull ?: 7).coerceIn(1, MAX_DAYS)
|
||||
val limit = (args?.get("limit")?.jsonPrimitive?.intOrNull ?: 20).coerceIn(1, MAX_DETAILS)
|
||||
val requestedUserId = args?.get("userId")?.jsonPrimitive?.longOrNull?.takeIf { it > 0 }
|
||||
val usageType = args?.get("usageType")?.jsonPrimitive?.contentOrNull?.lowercase()
|
||||
require(usageType == null || usageType in USAGE_TYPES) { "不支持的模型用途:$usageType" }
|
||||
val scope = queryScope(
|
||||
botId = event.bot.id,
|
||||
senderId = event.sender.id,
|
||||
currentGroupId = (event as? GroupMessageEvent)?.group?.id,
|
||||
requestedUserId = requestedUserId,
|
||||
)
|
||||
|
||||
val result = buildJsonObject {
|
||||
put("operation", operation)
|
||||
if (operation == "summary" || operation == "details" || operation == "overview") {
|
||||
if (!TokenUsageStore.isAvailable) {
|
||||
put("usageError", "模型用量 SQLite 尚未初始化")
|
||||
} else {
|
||||
val startDate = LocalDate.now(ZoneId.systemDefault()).minusDays((days - 1).toLong()).toString()
|
||||
val summary = TokenUsageStore.summary(
|
||||
startDate = startDate,
|
||||
botId = scope.botId,
|
||||
userId = scope.userId,
|
||||
groupId = scope.groupId,
|
||||
privateOnly = scope.privateOnly,
|
||||
usageKind = usageType,
|
||||
rankingLimit = limit,
|
||||
)
|
||||
putJsonObject("usage") { writeSummary(summary, days) }
|
||||
if (operation == "details") {
|
||||
putJsonArray("details") {
|
||||
TokenUsageStore.recent(
|
||||
limit = limit,
|
||||
startDate = startDate,
|
||||
botId = scope.botId,
|
||||
userId = scope.userId,
|
||||
groupId = scope.groupId,
|
||||
privateOnly = scope.privateOnly,
|
||||
usageKind = usageType,
|
||||
).forEach { record -> addRecord(record) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (operation == "balance" || operation == "overview") {
|
||||
put("balance", queryBalances())
|
||||
}
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
private fun kotlinx.serialization.json.JsonObjectBuilder.writeSummary(summary: TokenUsageSummary, days: Int) {
|
||||
put("days", days)
|
||||
put("promptTokens", summary.promptTokens)
|
||||
put("completionTokens", summary.completionTokens)
|
||||
put("totalTokens", summary.totalTokens)
|
||||
put("cachedTokens", summary.cachedTokens)
|
||||
put("tokenCallCount", summary.callCount)
|
||||
put("allCallCount", summary.allCallCount)
|
||||
put("activeUsers", summary.activeUsers)
|
||||
put("allActiveUsers", summary.allActiveUsers)
|
||||
put("todayTotalTokens", summary.todayTotal)
|
||||
put("cacheHitRatePercent", if (summary.promptTokens > 0) summary.cachedTokens * 100.0 / summary.promptTokens else 0.0)
|
||||
putJsonArray("daily") {
|
||||
summary.daily.forEach { daily ->
|
||||
addJsonObject {
|
||||
put("date", daily.date)
|
||||
put("totalTokens", daily.totalTokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
putJsonArray("usageDaily") {
|
||||
summary.usageDaily.forEach { daily ->
|
||||
addJsonObject {
|
||||
put("date", daily.date)
|
||||
put("usageKind", daily.usageKind)
|
||||
put("unit", daily.unit)
|
||||
put("totalUnits", daily.totalUnits)
|
||||
put("callCount", daily.callCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
putJsonArray("models") {
|
||||
summary.models.forEach { model ->
|
||||
addJsonObject {
|
||||
put("provider", model.provider)
|
||||
put("model", model.model)
|
||||
put("totalTokens", model.totalTokens)
|
||||
put("callCount", model.callCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
putJsonArray("breakdown") {
|
||||
summary.breakdown.forEach { item ->
|
||||
addJsonObject {
|
||||
put("provider", item.provider)
|
||||
put("model", item.model)
|
||||
put("usageKind", item.usageKind)
|
||||
put("unit", item.unit)
|
||||
put("inputUnits", item.inputUnits)
|
||||
put("outputUnits", item.outputUnits)
|
||||
put("totalUnits", item.totalUnits)
|
||||
put("callCount", item.callCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
putJsonArray("userUsage") {
|
||||
summary.userUsage.forEach { item ->
|
||||
addJsonObject {
|
||||
put("userId", item.userId)
|
||||
put("name", item.name)
|
||||
put("usageKind", item.usageKind)
|
||||
put("unit", item.unit)
|
||||
put("totalUnits", item.totalUnits)
|
||||
put("callCount", item.callCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
putJsonArray("topUsers") {
|
||||
summary.topUsers.forEach { ranking ->
|
||||
addJsonObject {
|
||||
put("userId", ranking.id)
|
||||
put("name", ranking.name)
|
||||
put("totalTokens", ranking.totalTokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
putJsonArray("topGroups") {
|
||||
summary.topGroups.forEach { ranking ->
|
||||
addJsonObject {
|
||||
put("groupId", ranking.id)
|
||||
put("name", ranking.name)
|
||||
put("totalTokens", ranking.totalTokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun kotlinx.serialization.json.JsonArrayBuilder.addRecord(record: TokenUsageRecord) {
|
||||
addJsonObject {
|
||||
put("date", record.date)
|
||||
put("timestamp", record.timestamp)
|
||||
put("userId", record.userId)
|
||||
put("userName", record.userNickname)
|
||||
record.groupId?.let { put("groupId", it) }
|
||||
record.groupName?.let { put("groupName", it) }
|
||||
record.provider?.let { put("provider", it) }
|
||||
record.model?.let { put("model", it) }
|
||||
put("usageKind", record.usageKind)
|
||||
put("unit", record.unit)
|
||||
put("inputUnits", record.inputUnits)
|
||||
put("outputUnits", record.outputUnits)
|
||||
put("totalUnits", record.totalUnits)
|
||||
put("promptTokens", record.promptTokens)
|
||||
put("completionTokens", record.completionTokens)
|
||||
put("totalTokens", record.totalTokens)
|
||||
put("cachedTokens", record.cachedTokens)
|
||||
put("callCount", record.callCount)
|
||||
put("detailed", record.detailed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun configuredDeepSeekAccounts(): List<BalanceAccount> {
|
||||
val openAiTypes = setOf("openai", "openai-compatible", "openai_compatible")
|
||||
fun resolveOpenAiAlias(alias: String) = ModelCatalog.resolve(alias)?.takeIf {
|
||||
it.providerType in openAiTypes && it.api.isNotBlank() && it.token.isNotBlank() && it.model.isNotBlank()
|
||||
}
|
||||
val boundAliases = buildSet {
|
||||
add(PluginConfig.chatModelAlias)
|
||||
addAll(PluginConfig.chatFallbackModelAliases)
|
||||
add(PluginConfig.profileModelAlias.ifBlank { PluginConfig.chatModelAlias })
|
||||
add(PluginConfig.reasoningModelAlias)
|
||||
add(PluginConfig.visualModelAlias)
|
||||
add(PluginConfig.webSummaryModelAlias)
|
||||
}.mapTo(HashSet(), String::trim).filterTo(HashSet(), String::isNotEmpty)
|
||||
val boundModels = boundAliases.mapNotNull { alias ->
|
||||
resolveOpenAiAlias(alias)?.let { definition ->
|
||||
ModelDefinition(
|
||||
name = definition.alias,
|
||||
provider = definition.provider,
|
||||
model = definition.model,
|
||||
)
|
||||
}
|
||||
}
|
||||
val legacyAccounts = buildList {
|
||||
val primaryResolved = resolveOpenAiAlias(PluginConfig.chatModelAlias) != null
|
||||
if (!primaryResolved) {
|
||||
add(BalanceAccount("legacy-chat", PluginConfig.openAiApi, PluginConfig.openAiToken))
|
||||
PluginConfig.chatFallbacks.forEachIndexed { index, fallback ->
|
||||
add(
|
||||
BalanceAccount(
|
||||
name = "legacy-chat-fallback-${index + 1}",
|
||||
api = fallback.api.ifBlank { PluginConfig.openAiApi },
|
||||
token = fallback.token.ifBlank { PluginConfig.openAiToken },
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
val profileAlias = PluginConfig.profileModelAlias.ifBlank { PluginConfig.chatModelAlias }
|
||||
if (PluginConfig.profileEnabled && resolveOpenAiAlias(profileAlias) == null) {
|
||||
add(
|
||||
BalanceAccount(
|
||||
"legacy-profile",
|
||||
PluginConfig.profileModelApi.ifBlank { PluginConfig.openAiApi },
|
||||
PluginConfig.profileModelToken.ifBlank { PluginConfig.openAiToken },
|
||||
)
|
||||
)
|
||||
}
|
||||
if (resolveOpenAiAlias(PluginConfig.reasoningModelAlias) == null) {
|
||||
add(
|
||||
BalanceAccount(
|
||||
"legacy-reasoning",
|
||||
PluginConfig.reasoningModelApi,
|
||||
PluginConfig.reasoningModelToken,
|
||||
)
|
||||
)
|
||||
}
|
||||
if (resolveOpenAiAlias(PluginConfig.visualModelAlias) == null) {
|
||||
add(BalanceAccount("legacy-visual", PluginConfig.visualModelApi, PluginConfig.visualModelToken))
|
||||
}
|
||||
if (resolveOpenAiAlias(PluginConfig.webSummaryModelAlias) == null) {
|
||||
add(
|
||||
BalanceAccount(
|
||||
"legacy-web-summary",
|
||||
PluginConfig.webSummaryModelApi,
|
||||
PluginConfig.webSummaryModelToken,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return collectDeepSeekBalanceAccounts(
|
||||
providers = ModelConfig.providers,
|
||||
models = boundModels,
|
||||
legacyAccounts = legacyAccounts,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun queryBalances(): JsonObject {
|
||||
val accounts = configuredDeepSeekAccounts()
|
||||
if (accounts.isEmpty()) {
|
||||
return buildJsonObject {
|
||||
put("supported", false)
|
||||
put("message", "未配置可查询余额的官方 DeepSeek 账号")
|
||||
}
|
||||
}
|
||||
val results = accounts.map { account -> queryDeepSeekBalance(account) }
|
||||
return buildJsonObject {
|
||||
put("supported", true)
|
||||
putJsonArray("accounts") {
|
||||
results.forEach(::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryDeepSeekBalance(account: BalanceAccount): JsonObject = try {
|
||||
val response = httpClient.get(DEEPSEEK_BALANCE_URL) {
|
||||
header(HttpHeaders.Authorization, "Bearer ${account.token}")
|
||||
timeout {
|
||||
requestTimeoutMillis = 20_000
|
||||
connectTimeoutMillis = 10_000
|
||||
socketTimeoutMillis = 20_000
|
||||
}
|
||||
}
|
||||
val body = response.bodyAsText()
|
||||
if (!response.status.isSuccess()) {
|
||||
buildJsonObject {
|
||||
put("provider", account.name)
|
||||
put("service", "deepseek")
|
||||
put("error", "HTTP ${response.status.value}")
|
||||
put("message", body.take(300))
|
||||
}
|
||||
} else {
|
||||
buildJsonObject {
|
||||
put("provider", account.name)
|
||||
put("service", "deepseek")
|
||||
put("data", parseDeepSeekBalance(body))
|
||||
}
|
||||
}
|
||||
} catch (cause: Throwable) {
|
||||
buildJsonObject {
|
||||
put("provider", account.name)
|
||||
put("service", "deepseek")
|
||||
put("error", cause.message ?: cause::class.simpleName.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,12 +2,16 @@ package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.ChatCompletionRequest
|
||||
import com.aallam.openai.api.chat.ChatMessage
|
||||
import com.aallam.openai.api.chat.StreamOptions
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import com.aallam.openai.api.core.Usage
|
||||
import com.aallam.openai.api.model.ModelId
|
||||
import kotlinx.serialization.json.*
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import top.jie65535.mirai.data.ModelUsageRecorder
|
||||
import top.jie65535.mirai.llm.LargeLanguageModels
|
||||
import top.jie65535.mirai.llm.ModelService
|
||||
|
||||
class ReasoningAgent : BaseAgent(
|
||||
tool = Tool.function(
|
||||
@@ -33,17 +37,23 @@ class ReasoningAgent : BaseAgent(
|
||||
override val isEnabled: Boolean
|
||||
get() = LargeLanguageModels.reasoning != null
|
||||
|
||||
override suspend fun execute(args: JsonObject?): String {
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
val llm = LargeLanguageModels.reasoning ?: return "未配置llm,无法进行推理。"
|
||||
val endpoint = LargeLanguageModels.reasoning ?: return "未配置llm,无法进行推理。"
|
||||
|
||||
val prompt = args.getValue("prompt").jsonPrimitive.content
|
||||
val answerContent = StringBuilder()
|
||||
val reasoningContent = StringBuilder()
|
||||
llm.chatCompletions(ChatCompletionRequest(
|
||||
model = ModelId(PluginConfig.reasoningModel),
|
||||
messages = listOf(ChatMessage.User(prompt))
|
||||
)).collect {
|
||||
var lastUsage: Usage? = null
|
||||
var cacheUsage: ModelService.CacheUsage? = null
|
||||
endpoint.service.chatCompletions(
|
||||
ChatCompletionRequest(
|
||||
model = ModelId(endpoint.model),
|
||||
messages = listOf(ChatMessage.User(prompt)),
|
||||
streamOptions = StreamOptions(includeUsage = true),
|
||||
)
|
||||
) { cacheUsage = it }.collect {
|
||||
it.usage?.let { usage -> lastUsage = usage }
|
||||
if (it.choices.isNotEmpty()) {
|
||||
val delta = it.choices[0].delta ?: return@collect
|
||||
if (!delta.reasoningContent.isNullOrEmpty()) {
|
||||
@@ -57,10 +67,21 @@ class ReasoningAgent : BaseAgent(
|
||||
|
||||
val result = answerContent.toString()
|
||||
val reasoning = reasoningContent.toString()
|
||||
return when {
|
||||
ModelUsageRecorder.recordTokens(
|
||||
event = event,
|
||||
endpointLabel = "reasoning",
|
||||
modelAlias = endpoint.alias,
|
||||
provider = endpoint.provider,
|
||||
model = endpoint.model,
|
||||
usageKind = "reasoning",
|
||||
usage = lastUsage,
|
||||
cacheUsage = cacheUsage,
|
||||
)
|
||||
val output = when {
|
||||
result.isNotEmpty() -> result
|
||||
reasoning.isNotEmpty() -> reasoning
|
||||
else -> "推理出错,结果为空"
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import net.mamoe.mirai.event.events.MessageEvent
|
||||
import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import top.jie65535.mirai.data.ModelUsageRecorder
|
||||
import top.jie65535.mirai.llm.ModelCatalog
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.measureTime
|
||||
@@ -48,22 +50,24 @@ class SendVoiceMessage : BaseAgent(
|
||||
get() = "录音中..."
|
||||
|
||||
override val isEnabled: Boolean
|
||||
get() = PluginConfig.dashScopeApiKey.isNotEmpty()
|
||||
get() = ModelCatalog.resolveTts() != null
|
||||
|
||||
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
if (event.subject !is AudioSupported) return "当前聊天环境不支持发送语音!"
|
||||
val modelDefinition = ModelCatalog.resolveTts()
|
||||
?: return "未配置 TTS 模型,无法生成语音。"
|
||||
|
||||
val content = args.getValue("content").jsonPrimitive.content
|
||||
val instructions = args["instructions"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
|
||||
|
||||
// https://help.aliyun.com/zh/model-studio/qwen-tts
|
||||
val response = httpClient.post(API_URL) {
|
||||
val response = httpClient.post(modelDefinition.api.ifBlank { API_URL }) {
|
||||
contentType(ContentType("application", "json"))
|
||||
header("Authorization", "Bearer " + PluginConfig.dashScopeApiKey)
|
||||
header("Authorization", "Bearer " + modelDefinition.token)
|
||||
setBody(buildJsonObject {
|
||||
put("model", PluginConfig.ttsModel)
|
||||
put("model", modelDefinition.model)
|
||||
putJsonObject("input") {
|
||||
put("text", content)
|
||||
put("voice", "Chelsie") // Chelsie(女) Cherry(女) Ethan(男) Serena(女)
|
||||
@@ -82,6 +86,24 @@ class SendVoiceMessage : BaseAgent(
|
||||
.getValue("output").jsonObject
|
||||
.getValue("audio").jsonObject
|
||||
.getValue("url").jsonPrimitive.content
|
||||
val inputCharacters = (responseObject["usage"] as? JsonObject)
|
||||
?.let { usage ->
|
||||
usage["input_characters"]?.jsonPrimitive?.longOrNull
|
||||
?: usage["characters"]?.jsonPrimitive?.longOrNull
|
||||
}
|
||||
?.coerceAtLeast(0)
|
||||
?: content.codePointCount(0, content.length).toLong()
|
||||
ModelUsageRecorder.recordUnits(
|
||||
event = event,
|
||||
endpointLabel = "tts",
|
||||
modelAlias = modelDefinition.alias,
|
||||
provider = modelDefinition.provider,
|
||||
model = modelDefinition.model,
|
||||
usageKind = "tts",
|
||||
unit = "characters",
|
||||
inputUnits = inputCharacters,
|
||||
totalUnits = inputCharacters,
|
||||
)
|
||||
|
||||
val voiceFolder = JChatGPT.resolveDataFile("voice")
|
||||
voiceFolder.mkdir()
|
||||
|
||||
@@ -2,8 +2,10 @@ package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.ChatCompletionRequest
|
||||
import com.aallam.openai.api.chat.ChatMessage
|
||||
import com.aallam.openai.api.chat.StreamOptions
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import com.aallam.openai.api.core.Usage
|
||||
import com.aallam.openai.api.model.ModelId
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
@@ -16,9 +18,12 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.*
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import top.jie65535.mirai.data.ModelUsageRecorder
|
||||
import top.jie65535.mirai.llm.LargeLanguageModels
|
||||
import top.jie65535.mirai.llm.ModelService
|
||||
import java.net.InetAddress
|
||||
import java.net.URI
|
||||
import java.net.UnknownHostException
|
||||
@@ -142,7 +147,7 @@ class VisitWeb : BaseAgent(
|
||||
override val loadingMessage: String
|
||||
get() = "上网中..."
|
||||
|
||||
override suspend fun execute(args: JsonObject?): String {
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
val urlJson = args.getValue("url")
|
||||
val instruction = args["instruction"]
|
||||
@@ -162,12 +167,17 @@ class VisitWeb : BaseAgent(
|
||||
|
||||
return coroutineScope {
|
||||
urls.map { url ->
|
||||
async(Dispatchers.IO) { jinaReadPage(url, instruction, outputLimit) }
|
||||
async(Dispatchers.IO) { jinaReadPage(url, instruction, outputLimit, event) }
|
||||
}.awaitAll().joinToString("\n\n---\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun jinaReadPage(url: String, instruction: String, outputLimit: Int): String {
|
||||
private suspend fun jinaReadPage(
|
||||
url: String,
|
||||
instruction: String,
|
||||
outputLimit: Int,
|
||||
event: MessageEvent,
|
||||
): String {
|
||||
return try {
|
||||
val request = withContext(Dispatchers.IO) {
|
||||
createReaderRequest(
|
||||
@@ -182,7 +192,7 @@ class VisitWeb : BaseAgent(
|
||||
}
|
||||
val body = response.bodyAsText()
|
||||
if (response.status.isSuccess()) {
|
||||
summarizeOrExcerpt(url, body, instruction, outputLimit)
|
||||
summarizeOrExcerpt(url, body, instruction, outputLimit, event)
|
||||
} else {
|
||||
"Error fetching \"$url\": HTTP ${response.status.value} ${body.take(500)}"
|
||||
}
|
||||
@@ -198,6 +208,7 @@ class VisitWeb : BaseAgent(
|
||||
body: String,
|
||||
instruction: String,
|
||||
outputLimit: Int,
|
||||
event: MessageEvent,
|
||||
): String {
|
||||
val endpoint = LargeLanguageModels.webSummary
|
||||
if (endpoint == null) {
|
||||
@@ -207,6 +218,8 @@ class VisitWeb : BaseAgent(
|
||||
val input = prepareWebContent(body, PluginConfig.webSummaryMaxInputChars)
|
||||
val prompt = buildSummaryUserPrompt(url, instruction, input)
|
||||
val rawSummary = StringBuilder()
|
||||
var lastUsage: Usage? = null
|
||||
var cacheUsage: ModelService.CacheUsage? = null
|
||||
return try {
|
||||
endpoint.service.chatCompletions(
|
||||
ChatCompletionRequest(
|
||||
@@ -215,14 +228,26 @@ class VisitWeb : BaseAgent(
|
||||
ChatMessage.System(WEB_SUMMARY_SYSTEM_PROMPT),
|
||||
ChatMessage.User(prompt),
|
||||
),
|
||||
streamOptions = StreamOptions(includeUsage = true),
|
||||
)
|
||||
).collect { chunk ->
|
||||
) { cacheUsage = it }.collect { chunk ->
|
||||
chunk.usage?.let { usage -> lastUsage = usage }
|
||||
chunk.choices.firstOrNull()?.delta?.content?.let { content ->
|
||||
if (rawSummary.length < MAX_RAW_SUMMARY_CHARS) {
|
||||
rawSummary.append(content.take(MAX_RAW_SUMMARY_CHARS - rawSummary.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
ModelUsageRecorder.recordTokens(
|
||||
event = event,
|
||||
endpointLabel = "web-summary",
|
||||
modelAlias = endpoint.alias,
|
||||
provider = endpoint.provider,
|
||||
model = endpoint.model,
|
||||
usageKind = "web_summary",
|
||||
usage = lastUsage,
|
||||
cacheUsage = cacheUsage,
|
||||
)
|
||||
val summary = limitSummaryOutput(cleanSummary(rawSummary.toString()), outputLimit)
|
||||
if (summary.isBlank()) {
|
||||
logSummaryFallback(url, body.length, "模型返回为空")
|
||||
|
||||
@@ -4,9 +4,11 @@ import com.aallam.openai.api.chat.ChatCompletionRequest
|
||||
import com.aallam.openai.api.chat.ChatMessage
|
||||
import com.aallam.openai.api.chat.ContentPart
|
||||
import com.aallam.openai.api.chat.ImagePart
|
||||
import com.aallam.openai.api.chat.StreamOptions
|
||||
import com.aallam.openai.api.chat.TextPart
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import com.aallam.openai.api.core.Usage
|
||||
import com.aallam.openai.api.model.ModelId
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -24,7 +26,9 @@ import kotlinx.coroutines.sync.withPermit
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import top.jie65535.mirai.data.ModelUsageRecorder
|
||||
import top.jie65535.mirai.llm.LargeLanguageModels
|
||||
import top.jie65535.mirai.llm.ModelService
|
||||
import top.jie65535.mirai.util.RetryBackoff
|
||||
import java.net.URI
|
||||
|
||||
@@ -68,7 +72,7 @@ class VisualAgent : BaseAgent(
|
||||
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
val llm = LargeLanguageModels.visual ?: return "未配置llm,无法进行识别。"
|
||||
val endpoint = LargeLanguageModels.visual ?: return "未配置llm,无法进行识别。"
|
||||
val imageIndices = args["image_indices"]?.jsonArray
|
||||
?.map { it.jsonPrimitive.int }
|
||||
?.ifEmpty { null }
|
||||
@@ -124,16 +128,20 @@ class VisualAgent : BaseAgent(
|
||||
repeat(maxAttempts) { attempt ->
|
||||
try {
|
||||
val answerContent = StringBuilder()
|
||||
llm.chatCompletions(
|
||||
var lastUsage: Usage? = null
|
||||
var cacheUsage: ModelService.CacheUsage? = null
|
||||
endpoint.service.chatCompletions(
|
||||
ChatCompletionRequest(
|
||||
model = ModelId(PluginConfig.visualModel),
|
||||
model = ModelId(endpoint.model),
|
||||
messages = listOf(
|
||||
ChatMessage.User(
|
||||
content = messageContent
|
||||
)
|
||||
)
|
||||
),
|
||||
streamOptions = StreamOptions(includeUsage = true),
|
||||
)
|
||||
).collect {
|
||||
) { cacheUsage = it }.collect {
|
||||
it.usage?.let { usage -> lastUsage = usage }
|
||||
if (it.choices.isNotEmpty()) {
|
||||
val delta = it.choices[0].delta ?: return@collect
|
||||
if (!delta.content.isNullOrEmpty()) {
|
||||
@@ -142,6 +150,16 @@ class VisualAgent : BaseAgent(
|
||||
}
|
||||
}
|
||||
|
||||
ModelUsageRecorder.recordTokens(
|
||||
event = event,
|
||||
endpointLabel = "visual",
|
||||
modelAlias = endpoint.alias,
|
||||
provider = endpoint.provider,
|
||||
model = endpoint.model,
|
||||
usageKind = "visual",
|
||||
usage = lastUsage,
|
||||
cacheUsage = cacheUsage,
|
||||
)
|
||||
if (answerContent.isNotEmpty()) {
|
||||
return@withPermit answerContent.toString()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package top.jie65535.mirai.config
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ModelConfigMigrationTest {
|
||||
@Test
|
||||
fun migratesLegacySettingsAndDeduplicatesProvidersAndModels() {
|
||||
val legacy = LegacyModelSettings(
|
||||
chat = openAi("https://api.deepseek.com/v1/", "token-a", "deepseek-chat"),
|
||||
chatFallbacks = listOf(
|
||||
openAi("https://api.deepseek.com/v1", "token-b", "deepseek-chat"),
|
||||
),
|
||||
profile = openAi("https://api.deepseek.com/v1/", "token-a", "deepseek-chat"),
|
||||
reasoning = openAi("https://api.deepseek.com/v1/", "token-a", "deepseek-reasoner"),
|
||||
visual = openAi("https://dashscope.aliyuncs.com/compatible-mode/v1/", "token-c", "qwen-vl-plus"),
|
||||
webSummary = openAi("", "", ""),
|
||||
dashScopeToken = "dashscope-token",
|
||||
imageModel = "qwen-image-2.0",
|
||||
ttsModel = "qwen3-tts-flash",
|
||||
)
|
||||
|
||||
val result = ModelConfigMigration.migrate(emptyList(), emptyList(), ModelRoleBindings(), legacy)
|
||||
|
||||
assertEquals(4, result.providers.size)
|
||||
assertEquals(6, result.models.size)
|
||||
assertEquals("chat-main", result.bindings.chat)
|
||||
assertEquals("chat-main", result.bindings.profile)
|
||||
assertEquals(listOf("chat-fallback-1"), result.bindings.chatFallbacks)
|
||||
assertEquals("reasoning-main", result.bindings.reasoning)
|
||||
assertEquals("visual-main", result.bindings.visual)
|
||||
assertEquals("", result.bindings.webSummary)
|
||||
assertEquals("image-main", result.bindings.image)
|
||||
assertEquals("tts-main", result.bindings.tts)
|
||||
assertEquals(1, result.providers.count { it.type == "dashscope" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesExistingEntriesAndBindingsAndIsIdempotent() {
|
||||
val existingProvider = ModelProviderDefinition("custom", "openai", "https://api.deepseek.com/v1", "token-a")
|
||||
val existingModel = ModelDefinition("my-chat", "custom", "deepseek-chat")
|
||||
val bindings = ModelRoleBindings(chat = "my-chat", visual = "manual-visual")
|
||||
val legacy = LegacyModelSettings(
|
||||
chat = openAi("https://api.deepseek.com/v1/", "token-a", "deepseek-chat"),
|
||||
chatFallbacks = emptyList(),
|
||||
profile = openAi("https://api.deepseek.com/v1/", "token-a", "deepseek-chat"),
|
||||
reasoning = openAi("", "", ""),
|
||||
visual = openAi("https://example.com/v1", "other", "vision"),
|
||||
webSummary = openAi("", "", ""),
|
||||
dashScopeToken = "",
|
||||
imageModel = "qwen-image-2.0",
|
||||
ttsModel = "qwen3-tts-flash",
|
||||
)
|
||||
|
||||
val first = ModelConfigMigration.migrate(listOf(existingProvider), listOf(existingModel), bindings, legacy)
|
||||
assertEquals(listOf(existingProvider), first.providers)
|
||||
assertEquals(listOf(existingModel), first.models)
|
||||
assertEquals("my-chat", first.bindings.chat)
|
||||
assertEquals("my-chat", first.bindings.profile)
|
||||
assertEquals("manual-visual", first.bindings.visual)
|
||||
|
||||
val second = ModelConfigMigration.migrate(first.providers, first.models, first.bindings, legacy)
|
||||
assertEquals(first.providers, second.providers)
|
||||
assertEquals(first.models, second.models)
|
||||
assertEquals(first.bindings, second.bindings)
|
||||
assertEquals(0, second.addedProviders)
|
||||
assertEquals(0, second.addedModels)
|
||||
assertTrue(!second.changed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesFreshInstallEmptyWhenNoCredentialsExist() {
|
||||
val empty = openAi("", "", "")
|
||||
val legacy = LegacyModelSettings(
|
||||
chat = empty,
|
||||
chatFallbacks = emptyList(),
|
||||
profile = empty,
|
||||
reasoning = empty,
|
||||
visual = empty,
|
||||
webSummary = empty,
|
||||
dashScopeToken = "",
|
||||
imageModel = "qwen-image-2.0",
|
||||
ttsModel = "qwen3-tts-flash",
|
||||
)
|
||||
|
||||
val result = ModelConfigMigration.migrate(emptyList(), emptyList(), ModelRoleBindings(), legacy)
|
||||
|
||||
assertTrue(result.providers.isEmpty())
|
||||
assertTrue(result.models.isEmpty())
|
||||
assertEquals(ModelRoleBindings(), result.bindings)
|
||||
assertTrue(!result.changed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allocatesNewNamesWithoutOverwritingConflicts() {
|
||||
val providers = listOf(ModelProviderDefinition("deepseek", "openai", "https://other.example/v1", "other"))
|
||||
val models = listOf(ModelDefinition("chat-main", "deepseek", "other-model"))
|
||||
val empty = openAi("", "", "")
|
||||
val legacy = LegacyModelSettings(
|
||||
chat = openAi("https://api.deepseek.com/v1", "token-a", "deepseek-chat"),
|
||||
chatFallbacks = emptyList(),
|
||||
profile = empty,
|
||||
reasoning = empty,
|
||||
visual = empty,
|
||||
webSummary = empty,
|
||||
dashScopeToken = "",
|
||||
imageModel = "",
|
||||
ttsModel = "",
|
||||
)
|
||||
|
||||
val result = ModelConfigMigration.migrate(providers, models, ModelRoleBindings(), legacy)
|
||||
|
||||
assertEquals("deepseek-2", result.providers.last().name)
|
||||
assertEquals("chat-main-2", result.models.last().name)
|
||||
assertEquals("chat-main-2", result.bindings.chat)
|
||||
}
|
||||
|
||||
private fun openAi(api: String, token: String, model: String) = LegacyOpenAiModel(api, token, model)
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package top.jie65535.mirai.data
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.sql.DriverManager
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import kotlin.io.path.absolutePathString
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TokenUsageStoreTest {
|
||||
@Test
|
||||
fun recordsDetailedUsageAndScopesQueriesToCurrentConversation() {
|
||||
val directory = Files.createTempDirectory("jchatgpt-token-usage-test-")
|
||||
try {
|
||||
TokenUsageStore.init(directory.toFile())
|
||||
val now = Instant.now().epochSecond
|
||||
record(now, botId = 1, userId = 10, groupId = 100, total = 15, model = "deepseek-chat")
|
||||
record(now + 1, botId = 1, userId = 11, groupId = 100, total = 30, model = "deepseek-chat")
|
||||
record(now + 2, botId = 1, userId = 10, groupId = 200, total = 40, model = "model-b")
|
||||
record(now + 3, botId = 2, userId = 12, groupId = 100, total = 50, model = "model-c")
|
||||
record(now + 4, botId = 1, userId = 10, groupId = null, total = 60, model = "deepseek-chat")
|
||||
|
||||
val startDate = LocalDate.now().minusDays(1).toString()
|
||||
val global = TokenUsageStore.summary(startDate, rankingLimit = 20)
|
||||
assertEquals(195, global.totalTokens)
|
||||
assertEquals(5, global.callCount)
|
||||
assertEquals(3, global.activeUsers)
|
||||
|
||||
val currentGroup = TokenUsageStore.summary(
|
||||
startDate = startDate,
|
||||
botId = 1,
|
||||
groupId = 100,
|
||||
rankingLimit = 20,
|
||||
)
|
||||
assertEquals(45, currentGroup.totalTokens)
|
||||
assertEquals(2, currentGroup.callCount)
|
||||
assertEquals(45, currentGroup.todayTotal)
|
||||
assertEquals(45, currentGroup.usageDaily.sumOf { it.totalUnits })
|
||||
assertEquals(listOf(11L, 10L), currentGroup.topUsers.map(TokenUsageRanking::id))
|
||||
|
||||
val oneGroupUser = TokenUsageStore.summary(
|
||||
startDate = startDate,
|
||||
botId = 1,
|
||||
userId = 11,
|
||||
groupId = 100,
|
||||
)
|
||||
assertEquals(30, oneGroupUser.totalTokens)
|
||||
assertEquals(1, oneGroupUser.callCount)
|
||||
|
||||
val currentPrivateChat = TokenUsageStore.summary(
|
||||
startDate = startDate,
|
||||
botId = 1,
|
||||
userId = 10,
|
||||
privateOnly = true,
|
||||
)
|
||||
assertEquals(60, currentPrivateChat.totalTokens)
|
||||
assertEquals(1, currentPrivateChat.callCount)
|
||||
assertTrue(currentPrivateChat.topGroups.isEmpty())
|
||||
|
||||
val details = TokenUsageStore.recent(
|
||||
startDate = startDate,
|
||||
botId = 1,
|
||||
groupId = 100,
|
||||
)
|
||||
assertEquals(listOf(11L, 10L), details.map(TokenUsageRecord::userId))
|
||||
assertTrue(details.all(TokenUsageRecord::detailed))
|
||||
assertEquals(setOf("deepseek"), details.mapNotNull(TokenUsageRecord::provider).toSet())
|
||||
} finally {
|
||||
TokenUsageStore.close()
|
||||
directory.toFile().deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importsLegacyJsonIdempotentlyAndKeepsSourceFile() {
|
||||
val directory = Files.createTempDirectory("jchatgpt-token-legacy-test-")
|
||||
val legacy = directory.resolve("token_usage.json")
|
||||
legacy.toFile().writeText(
|
||||
"""
|
||||
[
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"userId": 123,
|
||||
"userNickname": "legacy-user",
|
||||
"groupId": 456,
|
||||
"groupName": "legacy-group",
|
||||
"promptTokens": 100,
|
||||
"completionTokens": 20,
|
||||
"totalTokens": 120,
|
||||
"cachedTokens": 40,
|
||||
"callCount": 3
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
)
|
||||
val warnings = mutableListOf<String>()
|
||||
val database = directory.resolve("chat-history.sqlite")
|
||||
try {
|
||||
TokenUsageStore.init(directory.toFile()) { message, _ -> warnings += message }
|
||||
assertLegacyImport()
|
||||
TokenUsageStore.close()
|
||||
|
||||
DriverManager.getConnection("jdbc:sqlite:${database.absolutePathString()}").use { connection ->
|
||||
connection.createStatement().use { statement ->
|
||||
statement.executeUpdate("DELETE FROM token_usage_meta WHERE key = 'legacy_json_sha256'")
|
||||
}
|
||||
}
|
||||
|
||||
TokenUsageStore.init(directory.toFile()) { message, _ -> warnings += message }
|
||||
assertLegacyImport()
|
||||
assertTrue(Files.isRegularFile(legacy))
|
||||
assertEquals(1, warnings.count { it.startsWith("已将 1 条旧 Token 聚合记录迁移") })
|
||||
|
||||
DriverManager.getConnection("jdbc:sqlite:${database.absolutePathString()}").use { connection ->
|
||||
connection.createStatement().use { statement ->
|
||||
statement.executeQuery("PRAGMA journal_mode").use { results ->
|
||||
assertTrue(results.next())
|
||||
assertEquals("wal", results.getString(1).lowercase())
|
||||
}
|
||||
statement.executeQuery("SELECT value FROM token_usage_meta WHERE key = 'schema_version'").use { results ->
|
||||
assertTrue(results.next())
|
||||
assertEquals("2", results.getString(1))
|
||||
}
|
||||
statement.executeQuery(
|
||||
"SELECT value FROM token_usage_meta WHERE key = 'legacy_json_sha256'"
|
||||
).use { results ->
|
||||
assertTrue(results.next())
|
||||
assertEquals(64, results.getString(1).length)
|
||||
}
|
||||
statement.executeQuery("PRAGMA table_info(token_usage_record)").use { results ->
|
||||
val columns = buildSet {
|
||||
while (results.next()) add(results.getString("name"))
|
||||
}
|
||||
assertEquals(
|
||||
setOf(
|
||||
"id",
|
||||
"occurred_at",
|
||||
"usage_date",
|
||||
"bot_id",
|
||||
"user_id",
|
||||
"user_nickname",
|
||||
"group_id",
|
||||
"group_name",
|
||||
"endpoint_label",
|
||||
"model_alias",
|
||||
"provider",
|
||||
"model",
|
||||
"usage_kind",
|
||||
"unit",
|
||||
"input_units",
|
||||
"output_units",
|
||||
"total_units",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
"cached_tokens",
|
||||
"call_count",
|
||||
"detailed",
|
||||
"legacy_key",
|
||||
),
|
||||
columns,
|
||||
)
|
||||
}
|
||||
statement.executeQuery("SELECT COUNT(*) FROM token_usage_record").use { results ->
|
||||
assertTrue(results.next())
|
||||
assertEquals(1, results.getInt(1))
|
||||
}
|
||||
statement.executeQuery("PRAGMA index_list(token_usage_record)").use { results ->
|
||||
val indexes = buildSet {
|
||||
while (results.next()) add(results.getString("name"))
|
||||
}
|
||||
assertTrue("idx_token_usage_kind_date" in indexes)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
TokenUsageStore.close()
|
||||
directory.toFile().deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsNonTokenUnitsOutOfTokenTotalsAndIncludesThemInBreakdown() {
|
||||
val directory = Files.createTempDirectory("jchatgpt-model-usage-test-")
|
||||
try {
|
||||
TokenUsageStore.init(directory.toFile())
|
||||
val now = Instant.now().epochSecond
|
||||
record(now, botId = 1, userId = 10, groupId = 100, total = 100, model = "chat-model")
|
||||
TokenUsageStore.recordUsage(
|
||||
ModelUsageEvent(
|
||||
timestamp = now + 1,
|
||||
botId = 1,
|
||||
userId = 10,
|
||||
userNickname = "user-10",
|
||||
groupId = 100,
|
||||
groupName = "group-100",
|
||||
endpointLabel = "image",
|
||||
modelAlias = "image-main",
|
||||
provider = "dashscope",
|
||||
model = "qwen-image",
|
||||
usageKind = "image",
|
||||
unit = "images",
|
||||
outputUnits = 1,
|
||||
totalUnits = 1,
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
)
|
||||
)
|
||||
TokenUsageStore.recordUsage(
|
||||
ModelUsageEvent(
|
||||
timestamp = now + 2,
|
||||
botId = 1,
|
||||
userId = 10,
|
||||
userNickname = "user-10",
|
||||
groupId = 100,
|
||||
groupName = "group-100",
|
||||
endpointLabel = "tts",
|
||||
modelAlias = "tts-main",
|
||||
provider = "dashscope",
|
||||
model = "qwen-tts",
|
||||
usageKind = "tts",
|
||||
unit = "characters",
|
||||
inputUnits = 50,
|
||||
totalUnits = 50,
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
)
|
||||
)
|
||||
|
||||
val summary = TokenUsageStore.summary(
|
||||
startDate = LocalDate.now().minusDays(1).toString(),
|
||||
botId = 1,
|
||||
groupId = 100,
|
||||
rankingLimit = 20,
|
||||
)
|
||||
assertEquals(100, summary.totalTokens)
|
||||
assertEquals(1, summary.callCount)
|
||||
assertEquals(3, summary.allCallCount)
|
||||
assertEquals(setOf("tokens", "images", "characters"), summary.breakdown.map { it.unit }.toSet())
|
||||
assertEquals(1, summary.breakdown.single { it.unit == "images" }.outputUnits)
|
||||
assertEquals(50, summary.breakdown.single { it.unit == "characters" }.inputUnits)
|
||||
assertEquals(1, summary.userUsage.single { it.usageKind == "image" }.totalUnits)
|
||||
assertEquals(50, summary.userUsage.single { it.usageKind == "tts" }.totalUnits)
|
||||
|
||||
val imageOnly = TokenUsageStore.summary(
|
||||
startDate = LocalDate.now().minusDays(1).toString(),
|
||||
botId = 1,
|
||||
groupId = 100,
|
||||
usageKind = "image",
|
||||
)
|
||||
assertEquals(0, imageOnly.totalTokens)
|
||||
assertEquals(0, imageOnly.callCount)
|
||||
assertEquals(1, imageOnly.allCallCount)
|
||||
assertEquals(1, imageOnly.allActiveUsers)
|
||||
assertEquals("images", imageOnly.breakdown.single().unit)
|
||||
assertEquals(10, imageOnly.userUsage.single().userId)
|
||||
assertEquals(1, imageOnly.usageDaily.single().totalUnits)
|
||||
|
||||
val details = TokenUsageStore.recent(botId = 1, groupId = 100)
|
||||
assertEquals(listOf("tts", "image", "chat"), details.map { it.usageKind })
|
||||
assertEquals("tts-main", details.first().modelAlias)
|
||||
} finally {
|
||||
TokenUsageStore.close()
|
||||
directory.toFile().deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migratesVersionOneTableAndBackfillsGenericTokenUnits() {
|
||||
val directory = Files.createTempDirectory("jchatgpt-token-v1-migration-test-")
|
||||
val database = directory.resolve("chat-history.sqlite")
|
||||
try {
|
||||
DriverManager.getConnection("jdbc:sqlite:${database.absolutePathString()}").use { connection ->
|
||||
connection.createStatement().use { statement ->
|
||||
statement.executeUpdate(
|
||||
"""
|
||||
CREATE TABLE token_usage_record(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
occurred_at INTEGER NOT NULL,
|
||||
usage_date TEXT NOT NULL,
|
||||
bot_id INTEGER,
|
||||
user_id INTEGER NOT NULL,
|
||||
user_nickname TEXT NOT NULL DEFAULT '',
|
||||
group_id INTEGER,
|
||||
group_name TEXT,
|
||||
endpoint_label TEXT,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cached_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
call_count INTEGER NOT NULL DEFAULT 1,
|
||||
detailed INTEGER NOT NULL DEFAULT 1,
|
||||
legacy_key TEXT UNIQUE
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
statement.executeUpdate(
|
||||
"CREATE TABLE token_usage_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
||||
)
|
||||
statement.executeUpdate(
|
||||
"INSERT INTO token_usage_meta(key, value) VALUES ('schema_version', '1')"
|
||||
)
|
||||
statement.executeUpdate(
|
||||
"""
|
||||
INSERT INTO token_usage_record(
|
||||
occurred_at, usage_date, bot_id, user_id, user_nickname,
|
||||
group_id, group_name, endpoint_label, provider, model,
|
||||
prompt_tokens, completion_tokens, total_tokens, cached_tokens
|
||||
) VALUES (1, '2026-08-01', 1, 10, 'user', 100, 'group',
|
||||
'primary', 'deepseek', 'deepseek-chat', 80, 20, 100, 40)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TokenUsageStore.init(directory.toFile())
|
||||
val record = TokenUsageStore.recent(
|
||||
startDate = "2026-08-01",
|
||||
endDate = "2026-08-01",
|
||||
).single()
|
||||
assertEquals("chat", record.usageKind)
|
||||
assertEquals("tokens", record.unit)
|
||||
assertEquals(80, record.inputUnits)
|
||||
assertEquals(20, record.outputUnits)
|
||||
assertEquals(100, record.totalUnits)
|
||||
assertEquals(100, TokenUsageStore.summary("2026-08-01", "2026-08-01").totalTokens)
|
||||
|
||||
TokenUsageStore.close()
|
||||
DriverManager.getConnection("jdbc:sqlite:${database.absolutePathString()}").use { connection ->
|
||||
connection.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT value FROM token_usage_meta WHERE key = 'schema_version'").use { results ->
|
||||
assertTrue(results.next())
|
||||
assertEquals("2", results.getString(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
TokenUsageStore.close()
|
||||
directory.toFile().deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertLegacyImport() {
|
||||
val summary = TokenUsageStore.summary("2026-08-01", "2026-08-01")
|
||||
assertEquals(100, summary.promptTokens)
|
||||
assertEquals(20, summary.completionTokens)
|
||||
assertEquals(120, summary.totalTokens)
|
||||
assertEquals(40, summary.cachedTokens)
|
||||
assertEquals(3, summary.callCount)
|
||||
assertEquals(
|
||||
0,
|
||||
TokenUsageStore.summary(
|
||||
startDate = "2026-08-01",
|
||||
endDate = "2026-08-01",
|
||||
botId = 1,
|
||||
groupId = 456,
|
||||
).totalTokens,
|
||||
)
|
||||
|
||||
val record = TokenUsageStore.recent(
|
||||
limit = 10,
|
||||
startDate = "2026-08-01",
|
||||
endDate = "2026-08-01",
|
||||
).single()
|
||||
assertFalse(record.detailed)
|
||||
assertEquals(3, record.callCount)
|
||||
assertEquals(100, record.inputUnits)
|
||||
assertEquals(20, record.outputUnits)
|
||||
assertEquals(120, record.totalUnits)
|
||||
}
|
||||
|
||||
private fun record(
|
||||
timestamp: Long,
|
||||
botId: Long,
|
||||
userId: Long,
|
||||
groupId: Long?,
|
||||
total: Int,
|
||||
model: String,
|
||||
) {
|
||||
TokenUsageStore.record(
|
||||
timestamp = timestamp,
|
||||
botId = botId,
|
||||
userId = userId,
|
||||
userNickname = "user-$userId",
|
||||
groupId = groupId,
|
||||
groupName = groupId?.let { "group-$it" },
|
||||
endpointLabel = "primary",
|
||||
apiBaseUrl = "https://api.deepseek.com/v1/",
|
||||
model = model,
|
||||
promptTokens = total - 5,
|
||||
completionTokens = 5,
|
||||
totalTokens = total,
|
||||
cachedTokens = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package top.jie65535.mirai.llm
|
||||
|
||||
import top.jie65535.mirai.config.ModelDefinition
|
||||
import top.jie65535.mirai.config.ModelProviderDefinition
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ModelCatalogTest {
|
||||
@Test
|
||||
fun resolvesModelAliasThroughProvider() {
|
||||
val resolved = ModelCatalog.resolve(
|
||||
alias = "chat-main",
|
||||
providers = listOf(
|
||||
ModelProviderDefinition(
|
||||
name = "deepseek",
|
||||
type = "openai",
|
||||
api = "https://api.deepseek.com/v1/",
|
||||
token = "secret",
|
||||
)
|
||||
),
|
||||
models = listOf(
|
||||
ModelDefinition(
|
||||
name = "chat-main",
|
||||
provider = "deepseek",
|
||||
model = "deepseek-chat",
|
||||
extraBody = "{\"thinking\":false}",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
requireNotNull(resolved)
|
||||
assertEquals("chat-main", resolved.alias)
|
||||
assertEquals("deepseek", resolved.provider)
|
||||
assertEquals("openai", resolved.providerType)
|
||||
assertEquals("deepseek-chat", resolved.model)
|
||||
assertEquals("secret", resolved.token)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsAmbiguousAliasesAndProviders() {
|
||||
val provider = ModelProviderDefinition("deepseek", "openai", "https://api.deepseek.com/v1/", "secret")
|
||||
val model = ModelDefinition("chat-main", "deepseek", "deepseek-chat")
|
||||
|
||||
assertNull(ModelCatalog.resolve("chat-main", listOf(provider), listOf(model, model)))
|
||||
assertNull(ModelCatalog.resolve("chat-main", listOf(provider, provider), listOf(model)))
|
||||
assertNull(
|
||||
ModelCatalog.resolve(
|
||||
"chat-main",
|
||||
listOf(provider),
|
||||
listOf(model.copy(provider = "missing")),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reportsInvalidCatalogEntries() {
|
||||
val issues = ModelCatalog.validationIssues(
|
||||
providers = listOf(
|
||||
ModelProviderDefinition("duplicate", "openai", "https://example.com/v1/", "token"),
|
||||
ModelProviderDefinition("duplicate", "unknown", "", ""),
|
||||
),
|
||||
models = listOf(
|
||||
ModelDefinition("same", "duplicate", "model-a"),
|
||||
ModelDefinition("same", "missing", ""),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(issues.any { it.contains("provider 名称重复") })
|
||||
assertTrue(issues.any { it.contains("模型别名重复") })
|
||||
assertTrue(issues.any { it.contains("type 不受支持") })
|
||||
assertTrue(issues.any { it.contains("未配置 token") })
|
||||
assertTrue(issues.any { it.contains("引用的 provider 无效") })
|
||||
assertTrue(issues.any { it.contains("未配置实际模型名") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reportsRoleProtocolMismatch() {
|
||||
val providers = listOf(
|
||||
ModelProviderDefinition(
|
||||
name = "dashscope-native",
|
||||
type = "dashscope",
|
||||
token = "secret",
|
||||
)
|
||||
)
|
||||
val models = listOf(ModelDefinition("image-main", "dashscope-native", "qwen-image"))
|
||||
|
||||
val issue = ModelCatalog.bindingValidationIssue(
|
||||
role = "主聊天",
|
||||
alias = "image-main",
|
||||
allowedTypes = setOf("openai"),
|
||||
providers = providers,
|
||||
models = models,
|
||||
)
|
||||
assertTrue(issue?.contains("不能使用 provider type dashscope") == true)
|
||||
assertNull(
|
||||
ModelCatalog.bindingValidationIssue(
|
||||
role = "图像",
|
||||
alias = "image-main",
|
||||
allowedTypes = setOf("dashscope"),
|
||||
providers = providers,
|
||||
models = models,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import top.jie65535.mirai.config.ModelDefinition
|
||||
import top.jie65535.mirai.config.ModelProviderDefinition
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class QueryTokenUsageAgentTest {
|
||||
@Test
|
||||
fun scopesUsageToCurrentGroupOrPrivateSender() {
|
||||
val group = QueryTokenUsageAgent.queryScope(
|
||||
botId = 1,
|
||||
senderId = 10,
|
||||
currentGroupId = 100,
|
||||
requestedUserId = 20,
|
||||
)
|
||||
assertEquals(1, group.botId)
|
||||
assertEquals(100, group.groupId)
|
||||
assertEquals(20, group.userId)
|
||||
assertFalse(group.privateOnly)
|
||||
|
||||
val private = QueryTokenUsageAgent.queryScope(
|
||||
botId = 1,
|
||||
senderId = 10,
|
||||
currentGroupId = null,
|
||||
requestedUserId = 999,
|
||||
)
|
||||
assertEquals(1, private.botId)
|
||||
assertEquals(null, private.groupId)
|
||||
assertEquals(10, private.userId)
|
||||
assertTrue(private.privateOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recognizesOnlyOfficialDeepSeekHosts() {
|
||||
assertTrue(QueryTokenUsageAgent.isDeepSeekApi("https://api.deepseek.com/v1/"))
|
||||
assertTrue(QueryTokenUsageAgent.isDeepSeekApi("https://cn.api.deepseek.com/v1"))
|
||||
assertFalse(QueryTokenUsageAgent.isDeepSeekApi("https://deepseek.example.com/v1"))
|
||||
assertFalse(QueryTokenUsageAgent.isDeepSeekApi("not a url"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesDeepSeekBalanceResponse() {
|
||||
val result = QueryTokenUsageAgent.parseDeepSeekBalance(
|
||||
"""
|
||||
{
|
||||
"is_available": true,
|
||||
"balance_infos": [
|
||||
{
|
||||
"currency": "CNY",
|
||||
"total_balance": "12.34",
|
||||
"granted_balance": "2.00",
|
||||
"topped_up_balance": "10.34"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
assertTrue(result.getValue("available").jsonPrimitive.boolean)
|
||||
val balance = result.getValue("balances").jsonArray.single().jsonObject
|
||||
assertEquals("CNY", balance.getValue("currency").jsonPrimitive.content)
|
||||
assertEquals("12.34", balance.getValue("total").jsonPrimitive.content)
|
||||
assertEquals("2.00", balance.getValue("granted").jsonPrimitive.content)
|
||||
assertEquals("10.34", balance.getValue("toppedUp").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun collectsReferencedDeepSeekAccountsAndDeduplicatesCredentials() {
|
||||
val accounts = QueryTokenUsageAgent.collectDeepSeekBalanceAccounts(
|
||||
providers = listOf(
|
||||
ModelProviderDefinition("deepseek-a", "openai", "https://api.deepseek.com/v1/", "same-token"),
|
||||
ModelProviderDefinition("deepseek-b", "openai", "https://cn.api.deepseek.com/v1/", "same-token"),
|
||||
ModelProviderDefinition("unused", "openai", "https://api.deepseek.com/v1/", "unused-token"),
|
||||
ModelProviderDefinition("dashscope", "dashscope", "https://dashscope.aliyuncs.com/api/v1/", "dash-token"),
|
||||
),
|
||||
models = listOf(
|
||||
ModelDefinition("chat", "deepseek-a", "deepseek-chat"),
|
||||
ModelDefinition("reasoning", "deepseek-b", "deepseek-reasoner"),
|
||||
ModelDefinition("image", "dashscope", "qwen-image"),
|
||||
),
|
||||
legacyAccounts = listOf(
|
||||
QueryTokenUsageAgent.Companion.BalanceAccount(
|
||||
"legacy-chat",
|
||||
"https://api.deepseek.com/v1/",
|
||||
"same-token",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(1, accounts.size)
|
||||
assertEquals("deepseek-a", accounts.single().name)
|
||||
assertEquals("same-token", accounts.single().token)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user