mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(android): clarify provider attention state
This commit is contained in:
@@ -2085,6 +2085,7 @@ class NodeRuntime(
|
||||
id = id,
|
||||
name = obj["name"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: id,
|
||||
provider = provider,
|
||||
available = obj.optionalBoolean("available"),
|
||||
supportsVision = "image" in inputTypes,
|
||||
supportsAudio = "audio" in inputTypes,
|
||||
supportsDocuments = "document" in inputTypes,
|
||||
@@ -2701,6 +2702,7 @@ data class GatewayModelSummary(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val provider: String,
|
||||
val available: Boolean?,
|
||||
val supportsVision: Boolean,
|
||||
val supportsAudio: Boolean,
|
||||
val supportsDocuments: Boolean,
|
||||
@@ -2883,6 +2885,15 @@ private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonP
|
||||
|
||||
private fun JsonObject?.boolean(key: String): Boolean = (this?.get(key) as? JsonPrimitive)?.content?.trim() == "true"
|
||||
|
||||
private fun JsonObject?.optionalBoolean(key: String): Boolean? =
|
||||
(this?.get(key) as? JsonPrimitive)?.content?.trim()?.lowercase()?.let { value ->
|
||||
when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun cronJobLastRunStatus(state: JsonObject?): String? =
|
||||
state
|
||||
.cronStatus("lastStatus")
|
||||
|
||||
@@ -304,8 +304,10 @@ private fun providerCommandSubtitle(
|
||||
models: List<GatewayModelSummary>,
|
||||
): String {
|
||||
if (!isConnected) return "Connect Gateway to load models"
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
val readyProviderCount = readyModelProviderCount(providers, models)
|
||||
if (readyProviderCount > 0) return "$readyProviderCount providers ready"
|
||||
val expiringProviderCount = expiringModelProviderCount(providers)
|
||||
if (expiringProviderCount > 0) return "$expiringProviderCount providers expiring"
|
||||
if (models.isNotEmpty()) return "${models.size} models available"
|
||||
return "Configure model access"
|
||||
}
|
||||
|
||||
@@ -192,6 +192,9 @@ private data class ProviderSetupRow(
|
||||
val name: String,
|
||||
val subtitle: String,
|
||||
val ready: Boolean,
|
||||
val available: Boolean,
|
||||
val statusLabel: String,
|
||||
val warning: Boolean,
|
||||
)
|
||||
|
||||
private data class ProviderRow(
|
||||
@@ -199,37 +202,60 @@ private data class ProviderRow(
|
||||
val name: String,
|
||||
val status: String,
|
||||
val ready: Boolean,
|
||||
val available: Boolean,
|
||||
val setupRequired: Boolean,
|
||||
val warning: Boolean,
|
||||
val modelCount: Int,
|
||||
)
|
||||
|
||||
/** Combines auth-provider readiness rows with catalog-only providers. */
|
||||
/** Combines auth-provider readiness rows with catalog-only browse providers. */
|
||||
private fun providerRows(
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): List<ProviderRow> {
|
||||
val modelCounts = models.groupingBy { it.provider }.eachCount()
|
||||
val availableProviderIds =
|
||||
models
|
||||
.filter(::modelAvailabilityUsable)
|
||||
.map { it.provider.normalizedProviderId() }
|
||||
.toSet()
|
||||
val authRows =
|
||||
providers.map { provider ->
|
||||
val ready = modelProviderReady(provider.status)
|
||||
val providerId = provider.id.normalizedProviderId()
|
||||
val authReady = modelProviderReady(provider.status)
|
||||
val expiring = modelProviderExpiring(provider.status)
|
||||
val available = providerId in availableProviderIds
|
||||
ProviderRow(
|
||||
id = provider.id,
|
||||
name = provider.displayName,
|
||||
status = if (ready) "Ready" else "Needs setup",
|
||||
ready = ready,
|
||||
status =
|
||||
when {
|
||||
authReady -> "Ready"
|
||||
expiring -> "Expiring"
|
||||
available -> "Available"
|
||||
else -> "Needs setup"
|
||||
},
|
||||
ready = authReady,
|
||||
available = available || authReady || expiring,
|
||||
setupRequired = !authReady && !available && !expiring,
|
||||
warning = expiring,
|
||||
modelCount = modelCounts[provider.id] ?: 0,
|
||||
)
|
||||
}
|
||||
// Static/catalog-only providers may expose models without a matching auth
|
||||
// provider row; keep them visible as ready providers.
|
||||
// Catalog-only providers can be browsed but are not a readiness signal.
|
||||
val missingAuthRows =
|
||||
modelCounts.keys
|
||||
.filter { provider -> authRows.none { it.id == provider } }
|
||||
.map { provider ->
|
||||
val available = provider.normalizedProviderId() in availableProviderIds
|
||||
ProviderRow(
|
||||
id = provider,
|
||||
name = providerDisplayName(provider),
|
||||
status = "Ready",
|
||||
ready = true,
|
||||
status = if (available) "Available" else "Catalog",
|
||||
ready = available,
|
||||
available = available,
|
||||
setupRequired = false,
|
||||
warning = false,
|
||||
modelCount = modelCounts[provider] ?: 0,
|
||||
)
|
||||
}
|
||||
@@ -245,6 +271,9 @@ private fun providerSetupRows(providerRows: List<ProviderRow>): List<ProviderSet
|
||||
name = providerDisplayName(id),
|
||||
subtitle = providerSetupSubtitle(id, row),
|
||||
ready = row?.ready == true,
|
||||
available = row?.available == true,
|
||||
statusLabel = providerSetupStatusLabel(row),
|
||||
warning = row?.warning == true || row?.setupRequired == true || row == null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -254,12 +283,24 @@ private fun providerSetupSubtitle(
|
||||
row: ProviderRow?,
|
||||
): String =
|
||||
when {
|
||||
row?.status == "Expiring" -> "Credential expires soon"
|
||||
row?.ready == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Ready"
|
||||
row != null -> "Finish setup to use ${row.name}"
|
||||
row?.available == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Available"
|
||||
row?.setupRequired == true -> "Finish setup to use ${row.name}"
|
||||
row != null && row.modelCount > 0 -> "${row.modelCount} catalog models"
|
||||
id == "ollama" -> "Use models running on your network"
|
||||
else -> "Add provider credentials on your Gateway"
|
||||
}
|
||||
|
||||
private fun providerSetupStatusLabel(row: ProviderRow?): String =
|
||||
when {
|
||||
row?.ready == true -> "Ready"
|
||||
row?.status == "Expiring" -> "Expiring"
|
||||
row?.available == true -> "Available"
|
||||
row?.setupRequired == false -> "Catalog"
|
||||
else -> "Setup"
|
||||
}
|
||||
|
||||
/** Normalizes gateway provider status strings into a ready/not-ready boolean. */
|
||||
internal fun modelProviderReady(status: String): Boolean {
|
||||
val normalized = status.trim().lowercase()
|
||||
@@ -270,6 +311,32 @@ internal fun modelProviderReady(status: String): Boolean {
|
||||
normalized == "static"
|
||||
}
|
||||
|
||||
private fun modelProviderExpiring(status: String): Boolean = status.trim().lowercase() == "expiring"
|
||||
|
||||
/** Counts providers with either ready auth status or currently available configured models. */
|
||||
internal fun readyModelProviderCount(
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): Int {
|
||||
val authReadyProviders = providers.filter { modelProviderReady(it.status) }.map { it.id.normalizedProviderId() }
|
||||
val availableModelProviders = models.filter(::modelAvailabilityUsable).map { it.provider.normalizedProviderId() }
|
||||
return (authReadyProviders + availableModelProviders).distinct().size
|
||||
}
|
||||
|
||||
// Older gateways did not emit `available`; keep those rows on the legacy
|
||||
// readiness path while still honoring explicit false from upgraded gateways.
|
||||
private fun modelAvailabilityUsable(model: GatewayModelSummary): Boolean = model.available != false
|
||||
|
||||
/** Counts auth-backed providers that can serve now but need renewal soon. */
|
||||
internal fun expiringModelProviderCount(providers: List<GatewayModelProviderSummary>): Int =
|
||||
providers
|
||||
.filter { modelProviderExpiring(it.status) }
|
||||
.map { it.id.normalizedProviderId() }
|
||||
.distinct()
|
||||
.size
|
||||
|
||||
private fun String.normalizedProviderId(): String = trim().lowercase()
|
||||
|
||||
/** Groups models by provider using the same display priority as provider rows. */
|
||||
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
|
||||
models
|
||||
@@ -299,7 +366,18 @@ private fun ProviderList(
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
if (rows.isEmpty()) {
|
||||
ProviderListRow(ProviderRow(id = "loading", name = "Provider catalog", status = if (refreshing) "Loading" else "No providers", ready = false, modelCount = 0))
|
||||
ProviderListRow(
|
||||
ProviderRow(
|
||||
id = "loading",
|
||||
name = "Provider catalog",
|
||||
status = if (refreshing) "Loading" else "No providers",
|
||||
ready = false,
|
||||
available = false,
|
||||
setupRequired = false,
|
||||
warning = false,
|
||||
modelCount = 0,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
val visibleRows = rows.take(5)
|
||||
visibleRows.forEachIndexed { index, row ->
|
||||
@@ -322,12 +400,12 @@ private fun ProviderOverviewPanel(
|
||||
onRefresh: () -> Unit,
|
||||
onSetup: () -> Unit,
|
||||
) {
|
||||
val readyCount = providerRows.count { it.ready }
|
||||
val needsSetupCount = providerRows.count { !it.ready }
|
||||
val readyCount = providerRows.count { it.available }
|
||||
val needsSetupCount = providerRows.count { it.setupRequired }
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ProviderMetricTile(label = "Ready", value = readyCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Available", value = readyCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Models", value = modelCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Setup", value = needsSetupCount.toString(), modifier = Modifier.weight(1f))
|
||||
}
|
||||
@@ -398,8 +476,14 @@ private fun ProviderSetupListRow(
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
Text(text = if (row.ready) "Ready" else "Setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
val statusColor =
|
||||
when {
|
||||
row.warning -> ClawTheme.colors.warning
|
||||
row.ready || row.available -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textMuted
|
||||
}
|
||||
Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(statusColor))
|
||||
Text(text = row.statusLabel, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open ${row.name}", modifier = Modifier.size(17.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
@@ -415,7 +499,13 @@ private fun ProviderListRow(row: ProviderRow) {
|
||||
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "Provider setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
val statusColor =
|
||||
when {
|
||||
row.warning || row.setupRequired -> ClawTheme.colors.warning
|
||||
row.ready || row.available -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textMuted
|
||||
}
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(statusColor))
|
||||
Text(text = row.status, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,10 @@ private val shellNavTabs = listOf(Tab.Overview, Tab.Chat, Tab.Voice, Tab.Setting
|
||||
private val shellContentInsets: WindowInsets
|
||||
@Composable get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
|
||||
internal fun shellBottomNavVisible(keyboardVisible: Boolean, commandOpen: Boolean): Boolean = !keyboardVisible && !commandOpen
|
||||
internal fun shellBottomNavVisible(
|
||||
keyboardVisible: Boolean,
|
||||
commandOpen: Boolean,
|
||||
): Boolean = !keyboardVisible && !commandOpen
|
||||
|
||||
/** Main post-onboarding shell that owns top-level Android navigation state. */
|
||||
@Composable
|
||||
@@ -342,7 +345,8 @@ private fun OverviewScreen(
|
||||
val cronStatus by viewModel.cronStatus.collectAsState()
|
||||
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val channelsSummary by viewModel.channelsSummary.collectAsState()
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
val readyProviderCount = readyModelProviderCount(providers, models)
|
||||
val expiringProviderCount = expiringModelProviderCount(providers)
|
||||
val attentionRows =
|
||||
homeAttentionRows(
|
||||
isConnected = isConnected,
|
||||
@@ -350,6 +354,7 @@ private fun OverviewScreen(
|
||||
channelsSummary = channelsSummary,
|
||||
nodesDevicesSummary = nodesDevicesSummary,
|
||||
readyProviderCount = readyProviderCount,
|
||||
expiringProviderCount = expiringProviderCount,
|
||||
)
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
@@ -460,6 +465,7 @@ private fun OverviewScreen(
|
||||
when {
|
||||
!isConnected -> "Offline"
|
||||
readyProviderCount > 0 -> "$readyProviderCount ready"
|
||||
expiringProviderCount > 0 -> "$expiringProviderCount expiring"
|
||||
models.isNotEmpty() -> "${models.size} models"
|
||||
else -> "Setup"
|
||||
},
|
||||
@@ -541,6 +547,7 @@ internal fun homeAttentionRows(
|
||||
channelsSummary: GatewayChannelsSummary,
|
||||
nodesDevicesSummary: GatewayNodesDevicesSummary,
|
||||
readyProviderCount: Int,
|
||||
expiringProviderCount: Int = 0,
|
||||
): List<HomeAttentionRow> =
|
||||
listOfNotNull(
|
||||
if (!isConnected) {
|
||||
@@ -563,7 +570,12 @@ internal fun homeAttentionRows(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (isConnected && readyProviderCount == 0) {
|
||||
if (isConnected && expiringProviderCount > 0) {
|
||||
HomeAttentionRow("Providers", "Provider auth expires soon", Icons.Outlined.Inventory2, Tab.ProvidersModels)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (isConnected && readyProviderCount == 0 && expiringProviderCount == 0) {
|
||||
HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.ProvidersModels)
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayModelProviderSummary
|
||||
import ai.openclaw.app.GatewayModelSummary
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -10,8 +13,108 @@ class ProviderModelStatusTest {
|
||||
assertTrue(modelProviderReady("static"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiringProviderStatusIsNotFullyReady() {
|
||||
assertFalse(modelProviderReady("expiring"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingProviderStatusIsNotReady() {
|
||||
assertFalse(modelProviderReady("missing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readyModelProviderCountUsesAuthBackedProviderStatuses() {
|
||||
val providers =
|
||||
listOf(
|
||||
GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "missing", profileCount = 0),
|
||||
GatewayModelProviderSummary(id = "anthropic", displayName = "Anthropic", status = "ready", profileCount = 1),
|
||||
GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "expiring", profileCount = 1),
|
||||
)
|
||||
|
||||
assertEquals(1, readyModelProviderCount(providers, emptyList()))
|
||||
assertEquals(1, expiringModelProviderCount(providers))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readyModelProviderCountUsesAvailableModelsAsServingReadiness() {
|
||||
val models =
|
||||
listOf(
|
||||
model(provider = "anthropic", available = true),
|
||||
model(provider = "anthropic", available = true),
|
||||
model(provider = "openrouter", available = false),
|
||||
)
|
||||
|
||||
assertEquals(1, readyModelProviderCount(emptyList(), models))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readyModelProviderCountDoesNotTreatCatalogOnlyModelsAsReady() {
|
||||
val providers =
|
||||
listOf(
|
||||
GatewayModelProviderSummary(id = "openrouter", displayName = "OpenRouter", status = "missing", profileCount = 0),
|
||||
)
|
||||
val models =
|
||||
listOf(
|
||||
model(provider = "openrouter", available = false),
|
||||
)
|
||||
|
||||
assertEquals(0, readyModelProviderCount(providers, models))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readyModelProviderCountPreservesLegacyRowsWhenAvailabilityIsMissing() {
|
||||
val models =
|
||||
listOf(
|
||||
model(provider = "openrouter", available = null),
|
||||
)
|
||||
|
||||
assertEquals(1, readyModelProviderCount(emptyList(), models))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readyModelProviderCountTreatsExpiringAvailableModelsAsUsableButWarnable() {
|
||||
val providers =
|
||||
listOf(
|
||||
GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "expiring", profileCount = 1),
|
||||
)
|
||||
val models =
|
||||
listOf(
|
||||
model(provider = "openai", available = true),
|
||||
)
|
||||
|
||||
assertEquals(1, readyModelProviderCount(providers, models))
|
||||
assertEquals(1, expiringModelProviderCount(providers))
|
||||
assertFalse(modelProviderReady("expiring"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readyModelProviderCountDoesNotTreatUnavailableModelsAsReadyWhenAuthProviderNeedsSetup() {
|
||||
val providers =
|
||||
listOf(
|
||||
GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "missing", profileCount = 0),
|
||||
)
|
||||
val models =
|
||||
listOf(
|
||||
model(provider = "openai", available = false),
|
||||
)
|
||||
|
||||
assertEquals(0, readyModelProviderCount(providers, models))
|
||||
}
|
||||
|
||||
private fun model(
|
||||
provider: String,
|
||||
available: Boolean?,
|
||||
): GatewayModelSummary =
|
||||
GatewayModelSummary(
|
||||
id = "$provider/test-model",
|
||||
name = "test-model",
|
||||
provider = provider,
|
||||
available = available,
|
||||
supportsVision = false,
|
||||
supportsAudio = false,
|
||||
supportsDocuments = false,
|
||||
supportsReasoning = false,
|
||||
contextTokens = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,6 +92,21 @@ class ShellScreenLogicTest {
|
||||
assertEquals(emptyList<String>(), rows.map { it.title })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsSurfaceExpiringProviderAuth() {
|
||||
val rows =
|
||||
homeAttentionRows(
|
||||
isConnected = true,
|
||||
pendingApprovals = 0,
|
||||
channelsSummary = emptyChannels(),
|
||||
nodesDevicesSummary = emptyNodesDevices(),
|
||||
readyProviderCount = 0,
|
||||
expiringProviderCount = 1,
|
||||
)
|
||||
|
||||
assertEquals(listOf("Provider auth expires soon"), rows.map { it.subtitle })
|
||||
}
|
||||
|
||||
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
|
||||
|
||||
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
|
||||
|
||||
@@ -4677,6 +4677,7 @@ public struct ModelChoice: Codable, Sendable {
|
||||
public let name: String
|
||||
public let provider: String
|
||||
public let alias: String?
|
||||
public let available: Bool?
|
||||
public let contextwindow: Int?
|
||||
public let reasoning: Bool?
|
||||
|
||||
@@ -4685,6 +4686,7 @@ public struct ModelChoice: Codable, Sendable {
|
||||
name: String,
|
||||
provider: String,
|
||||
alias: String?,
|
||||
available: Bool? = nil,
|
||||
contextwindow: Int?,
|
||||
reasoning: Bool?)
|
||||
{
|
||||
@@ -4692,6 +4694,7 @@ public struct ModelChoice: Codable, Sendable {
|
||||
self.name = name
|
||||
self.provider = provider
|
||||
self.alias = alias
|
||||
self.available = available
|
||||
self.contextwindow = contextwindow
|
||||
self.reasoning = reasoning
|
||||
}
|
||||
@@ -4701,6 +4704,7 @@ public struct ModelChoice: Codable, Sendable {
|
||||
case name
|
||||
case provider
|
||||
case alias
|
||||
case available
|
||||
case contextwindow = "contextWindow"
|
||||
case reasoning
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ModelChoiceSchema = Type.Object(
|
||||
name: NonEmptyString,
|
||||
provider: NonEmptyString,
|
||||
alias: Type.Optional(NonEmptyString),
|
||||
available: Type.Optional(Type.Boolean()),
|
||||
contextWindow: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
reasoning: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
|
||||
@@ -79,6 +79,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
|
||||
["CronRunLogEntry", ["errorReason", "failureNotificationDelivery"]],
|
||||
["ExecApprovalRequestParams", ["requireDeliveryRoute", "suppressDelivery"]],
|
||||
["AgentSummary", ["thinkingLevels", "thinkingOptions", "thinkingDefault"]],
|
||||
["ModelChoice", ["available"]],
|
||||
];
|
||||
|
||||
const DEFAULTED_OPTIONAL_INIT_PARAMS: Record<string, Set<string>> = Object.fromEntries(
|
||||
|
||||
@@ -44,20 +44,20 @@ function resolveModelCatalogBrowseTimeoutMs(value: number | undefined): number {
|
||||
}
|
||||
|
||||
/** Loads catalog entries for browse views, using read-only discovery unless full catalog is required. */
|
||||
export async function loadModelCatalogForBrowse(params: {
|
||||
export async function loadModelCatalogForBrowseWithState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
view?: ModelCatalogBrowseView;
|
||||
loadCatalog: (params: { readOnly: boolean }) => Promise<ModelCatalogEntry[]>;
|
||||
timeoutMs?: number;
|
||||
onTimeout?: (timeoutMs: number) => void;
|
||||
}): Promise<ModelCatalogEntry[]> {
|
||||
}): Promise<{ catalog: ModelCatalogEntry[]; timedOut: boolean }> {
|
||||
const view = params.view ?? "default";
|
||||
if (view === "all") {
|
||||
return await params.loadCatalog({ readOnly: false });
|
||||
return { catalog: await params.loadCatalog({ readOnly: false }), timedOut: false };
|
||||
}
|
||||
if (parseConfiguredModelVisibilityEntries({ cfg: params.cfg }).providerWildcards.size > 0) {
|
||||
// Wildcards depend on provider discovery; read-only cached entries can hide matching models.
|
||||
return await params.loadCatalog({ readOnly: false });
|
||||
return { catalog: await params.loadCatalog({ readOnly: false }), timedOut: false };
|
||||
}
|
||||
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
@@ -75,12 +75,23 @@ export async function loadModelCatalogForBrowse(params: {
|
||||
// The browse path may return partial/empty results; keep late catalog failures off stderr.
|
||||
catalogPromise.catch(() => undefined);
|
||||
params.onTimeout?.(timeoutMs);
|
||||
return [];
|
||||
return { catalog: [], timedOut: true };
|
||||
}
|
||||
return result;
|
||||
return { catalog: result, timedOut: false };
|
||||
} finally {
|
||||
if (timeout) {
|
||||
modelCatalogBrowseDeps.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads catalog entries for browse views, using read-only discovery unless full catalog is required. */
|
||||
export async function loadModelCatalogForBrowse(params: {
|
||||
cfg: OpenClawConfig;
|
||||
view?: ModelCatalogBrowseView;
|
||||
loadCatalog: (params: { readOnly: boolean }) => Promise<ModelCatalogEntry[]>;
|
||||
timeoutMs?: number;
|
||||
onTimeout?: (timeoutMs: number) => void;
|
||||
}): Promise<ModelCatalogEntry[]> {
|
||||
return (await loadModelCatalogForBrowseWithState(params)).catalog;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "./model-visibility-policy.js";
|
||||
|
||||
type ModelCatalogVisibilityView = "default" | "configured" | "all";
|
||||
type ProviderAuthChecker = (provider: string, modelApi?: string) => boolean | Promise<boolean>;
|
||||
export type ProviderAuthChecker = (provider: string, modelApi?: string) => boolean | Promise<boolean>;
|
||||
const OPENAI_PROVIDER_ID = "openai";
|
||||
const OPENAI_CODEX_RESPONSES_API = "openai-chatgpt-responses";
|
||||
const OPENAI_CODEX_ROUTABLE_MODEL_IDS = new Set([
|
||||
@@ -52,7 +52,7 @@ async function resolveProviderAuthCheck(
|
||||
return isPromiseLike(result) ? await result : result;
|
||||
}
|
||||
|
||||
async function providerHasAuth(
|
||||
export async function modelCatalogEntryHasProviderAuth(
|
||||
providerAuthChecker: ProviderAuthChecker,
|
||||
entry: ModelCatalogEntry,
|
||||
): Promise<boolean> {
|
||||
@@ -130,7 +130,7 @@ export async function resolveVisibleModelCatalog(params: {
|
||||
});
|
||||
const authBackedCatalog: ModelCatalogEntry[] = [];
|
||||
for (const entry of params.catalog) {
|
||||
if (await providerHasAuth(hasAuth, entry)) {
|
||||
if (await modelCatalogEntryHasProviderAuth(hasAuth, entry)) {
|
||||
authBackedCatalog.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Model list result building resolves visible model catalogs for an agent and
|
||||
// strips runtime-only provider params before sending the browse API payload.
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import {
|
||||
resolveAgentEffectiveModelPrimary,
|
||||
resolveAgentWorkspaceDir,
|
||||
@@ -7,15 +8,24 @@ import {
|
||||
} from "../../agents/agent-scope.js";
|
||||
import { DEFAULT_PROVIDER } from "../../agents/defaults.js";
|
||||
import {
|
||||
loadModelCatalogForBrowse,
|
||||
loadModelCatalogForBrowseWithState,
|
||||
type ModelCatalogBrowseView,
|
||||
} from "../../agents/model-catalog-browse.js";
|
||||
import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js";
|
||||
import {
|
||||
modelCatalogEntryHasProviderAuth,
|
||||
type ProviderAuthChecker,
|
||||
resolveVisibleModelCatalog,
|
||||
} from "../../agents/model-catalog-visibility.js";
|
||||
import { NON_ENV_SECRETREF_MARKER } from "../../agents/model-auth-markers.js";
|
||||
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
|
||||
import { createProviderAuthChecker } from "../../agents/model-provider-auth.js";
|
||||
import { isSecretRef } from "../../config/types.secrets.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
|
||||
import type { GatewayRequestContext } from "./types.js";
|
||||
|
||||
type ModelsListView = ModelCatalogBrowseView;
|
||||
type ModelsListEntry = ModelCatalogEntry & { available?: boolean };
|
||||
|
||||
let loggedSlowModelsListCatalog = false;
|
||||
|
||||
@@ -34,20 +44,88 @@ function omitRuntimeModelParams(entry: ModelCatalogEntry): ModelCatalogEntry {
|
||||
return rest;
|
||||
}
|
||||
|
||||
function omitRuntimeModelParamsFromCatalog(catalog: ModelCatalogEntry[]): ModelCatalogEntry[] {
|
||||
return catalog.map(omitRuntimeModelParams);
|
||||
function modelCatalogEntryHasUnknownSecretRefAvailability(
|
||||
cfg: OpenClawConfig,
|
||||
entry: ModelCatalogEntry,
|
||||
): boolean {
|
||||
const providerId = normalizeProviderId(entry.provider);
|
||||
const provider = Object.entries(cfg.models?.providers ?? {}).find(
|
||||
([id]) => normalizeProviderId(id) === providerId,
|
||||
)?.[1];
|
||||
const apiKey = provider?.apiKey;
|
||||
return apiKey === NON_ENV_SECRETREF_MARKER || (isSecretRef(apiKey) && apiKey.source !== "env");
|
||||
}
|
||||
|
||||
function createInFlightProviderAuthChecker(
|
||||
providerAuthChecker: ProviderAuthChecker,
|
||||
): ProviderAuthChecker {
|
||||
const pending = new Map<string, Promise<boolean>>();
|
||||
return (provider, modelApi) => {
|
||||
const key = `${normalizeProviderId(provider)}\0${modelApi ?? ""}`;
|
||||
const cached = pending.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const next = Promise.resolve(providerAuthChecker(provider, modelApi));
|
||||
pending.set(key, next);
|
||||
return next;
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPublicModelsListEntry(params: {
|
||||
entry: ModelCatalogEntry;
|
||||
cfg: OpenClawConfig;
|
||||
providerAuthChecker?: ProviderAuthChecker;
|
||||
}): Promise<ModelsListEntry> {
|
||||
const publicEntry = omitRuntimeModelParams(params.entry);
|
||||
if (modelCatalogEntryHasUnknownSecretRefAvailability(params.cfg, params.entry)) {
|
||||
return publicEntry;
|
||||
}
|
||||
if (!params.providerAuthChecker) {
|
||||
return publicEntry;
|
||||
}
|
||||
return {
|
||||
...publicEntry,
|
||||
available: await modelCatalogEntryHasProviderAuth(params.providerAuthChecker, params.entry),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPublicModelsListEntries(params: {
|
||||
catalog: ModelCatalogEntry[];
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
workspaceDir: string;
|
||||
}): Promise<ModelsListEntry[]> {
|
||||
const inFlightProviderAuthChecker = createInFlightProviderAuthChecker(
|
||||
createProviderAuthChecker({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
allowPluginSyntheticAuth: false,
|
||||
discoverExternalCliAuth: false,
|
||||
}),
|
||||
);
|
||||
return await Promise.all(
|
||||
params.catalog.map((entry) =>
|
||||
buildPublicModelsListEntry({
|
||||
entry,
|
||||
cfg: params.cfg,
|
||||
providerAuthChecker: inFlightProviderAuthChecker,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildModelsListResult(params: {
|
||||
context: GatewayRequestContext;
|
||||
agentId?: string;
|
||||
params: Record<string, unknown>;
|
||||
}): Promise<{ models: ModelCatalogEntry[] }> {
|
||||
}): Promise<{ models: ModelsListEntry[] }> {
|
||||
const cfg = params.context.getRuntimeConfig();
|
||||
const agentId = params.agentId ?? resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir();
|
||||
const view = resolveModelsListView(params.params);
|
||||
const catalog = await loadModelCatalogForBrowse({
|
||||
const { catalog } = await loadModelCatalogForBrowseWithState({
|
||||
cfg,
|
||||
view,
|
||||
loadCatalog: params.context.loadGatewayModelCatalog,
|
||||
@@ -62,7 +140,9 @@ export async function buildModelsListResult(params: {
|
||||
},
|
||||
});
|
||||
if (view === "all") {
|
||||
return { models: omitRuntimeModelParamsFromCatalog(catalog) };
|
||||
return {
|
||||
models: await buildPublicModelsListEntries({ catalog, cfg, agentId, workspaceDir }),
|
||||
};
|
||||
}
|
||||
const models = await resolveVisibleModelCatalog({
|
||||
cfg,
|
||||
@@ -74,5 +154,12 @@ export async function buildModelsListResult(params: {
|
||||
view,
|
||||
runtimeAuthDiscovery: false,
|
||||
});
|
||||
return { models: omitRuntimeModelParamsFromCatalog(models) };
|
||||
return {
|
||||
models: await buildPublicModelsListEntries({
|
||||
catalog: models,
|
||||
cfg,
|
||||
agentId,
|
||||
workspaceDir,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("models.list", () => {
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
const { request, respond } = requestModelsList({
|
||||
view: "configured",
|
||||
@@ -63,6 +63,7 @@ describe("models.list", () => {
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await request;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
@@ -73,6 +74,7 @@ describe("models.list", () => {
|
||||
id: "gpt-test",
|
||||
name: "GPT Test",
|
||||
provider: "openai",
|
||||
available: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -84,11 +86,60 @@ describe("models.list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps SecretRef configured fallback rows unknown when catalog discovery times out", async () => {
|
||||
const catalog = createDeferred<never>();
|
||||
const loadGatewayModelCatalog = vi.fn(() => catalog.promise);
|
||||
const runtimeConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
vllm: {
|
||||
apiKey: {
|
||||
source: "file",
|
||||
provider: "mounted-json",
|
||||
id: "/providers/vllm/apiKey",
|
||||
},
|
||||
models: [{ id: "llama-secure", name: "Llama Secure" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
const { request, respond } = requestModelsList({
|
||||
view: "configured",
|
||||
runtimeConfig,
|
||||
loadGatewayModelCatalog,
|
||||
reqId: "req-models-list-secretref-timeout",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await request;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: "llama-secure",
|
||||
name: "Llama Secure",
|
||||
provider: "vllm",
|
||||
},
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the all view exact instead of timing out to a partial catalog", async () => {
|
||||
const catalog = createDeferred<[{ id: string; name: string; provider: string }]>();
|
||||
const loadGatewayModelCatalog = vi.fn(() => catalog.promise);
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
const { request, respond } = requestModelsList({
|
||||
view: "all",
|
||||
@@ -100,11 +151,12 @@ describe("models.list", () => {
|
||||
expect(respond).not.toHaveBeenCalled();
|
||||
|
||||
catalog.resolve([{ id: "gpt-test", name: "GPT Test", provider: "openai" }]);
|
||||
await vi.runAllTimersAsync();
|
||||
await request;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{ models: [{ id: "gpt-test", name: "GPT Test", provider: "openai" }] },
|
||||
{ models: [{ id: "gpt-test", name: "GPT Test", provider: "openai", available: false }] },
|
||||
undefined,
|
||||
);
|
||||
expect(loadGatewayModelCatalog).toHaveBeenCalledWith({ readOnly: false });
|
||||
@@ -132,7 +184,7 @@ describe("models.list", () => {
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{ models: [{ id: "qwen-local", name: "Qwen Local", provider: "vllm" }] },
|
||||
{ models: [{ id: "qwen-local", name: "Qwen Local", provider: "vllm", available: false }] },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -175,10 +227,10 @@ describe("models.list", () => {
|
||||
true,
|
||||
{
|
||||
models: [
|
||||
{ id: "gpt-5.4-codex", name: "GPT-5.4 Codex", provider: "openai" },
|
||||
{ id: "gpt-codex-test", name: "GPT Codex Test", provider: "openai" },
|
||||
{ id: "llama-local", name: "Llama Local", provider: "vllm" },
|
||||
{ id: "qwen-local", name: "Qwen Local", provider: "vllm" },
|
||||
{ id: "gpt-5.4-codex", name: "GPT-5.4 Codex", provider: "openai", available: true },
|
||||
{ id: "gpt-codex-test", name: "GPT Codex Test", provider: "openai", available: true },
|
||||
{ id: "llama-local", name: "Llama Local", provider: "vllm", available: true },
|
||||
{ id: "qwen-local", name: "Qwen Local", provider: "vllm", available: true },
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
@@ -193,7 +245,91 @@ describe("models.list", () => {
|
||||
});
|
||||
await allRequest;
|
||||
|
||||
expect(allRespond).toHaveBeenCalledWith(true, { models: catalog }, undefined);
|
||||
expect(allRespond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
models: [
|
||||
{ id: "claude-test", name: "Claude Test", provider: "anthropic", available: false },
|
||||
{ id: "gpt-5.4-codex", name: "GPT-5.4 Codex", provider: "openai", available: true },
|
||||
{ id: "gpt-codex-test", name: "GPT Codex Test", provider: "openai", available: true },
|
||||
{ id: "llama-local", name: "Llama Local", provider: "vllm", available: true },
|
||||
{ id: "qwen-local", name: "Qwen Local", provider: "vllm", available: true },
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps file SecretRef provider availability unknown instead of forcing unavailable", async () => {
|
||||
const catalog = [{ id: "llama-secure", name: "Llama Secure", provider: "vllm" }];
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"vllm/*": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
vllm: {
|
||||
apiKey: {
|
||||
source: "file",
|
||||
provider: "mounted-json",
|
||||
id: "/providers/vllm/apiKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const { request, respond } = requestModelsList({
|
||||
view: "all",
|
||||
runtimeConfig: cfg,
|
||||
loadGatewayModelCatalog: vi.fn(() => Promise.resolve(catalog)),
|
||||
reqId: "req-models-list-secretref-file",
|
||||
});
|
||||
await request;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{ models: [{ id: "llama-secure", name: "Llama Secure", provider: "vllm" }] },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps managed SecretRef provider availability unknown instead of forcing unavailable", async () => {
|
||||
const catalog = [{ id: "llama-managed", name: "Llama Managed", provider: "vllm" }];
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"vllm/*": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
vllm: {
|
||||
apiKey: "secretref-managed",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const { request, respond } = requestModelsList({
|
||||
view: "all",
|
||||
runtimeConfig: cfg,
|
||||
loadGatewayModelCatalog: vi.fn(() => Promise.resolve(catalog)),
|
||||
reqId: "req-models-list-secretref-managed",
|
||||
});
|
||||
await request;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{ models: [{ id: "llama-managed", name: "Llama Managed", provider: "vllm" }] },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves catalog load errors before the timeout fallback wins", async () => {
|
||||
|
||||
@@ -87,6 +87,7 @@ type ModelCatalogRpcEntry = {
|
||||
name: string;
|
||||
provider: string;
|
||||
alias?: string;
|
||||
available?: boolean;
|
||||
contextWindow?: number;
|
||||
input?: string[];
|
||||
reasoning?: boolean;
|
||||
@@ -126,24 +127,28 @@ const expectedSortedCatalog = (): ModelCatalogRpcEntry[] => [
|
||||
id: "claude-test-a",
|
||||
name: "A-Model",
|
||||
provider: "anthropic",
|
||||
available: false,
|
||||
contextWindow: 200_000,
|
||||
},
|
||||
{
|
||||
id: "claude-test-b",
|
||||
name: "B-Model",
|
||||
provider: "anthropic",
|
||||
available: false,
|
||||
contextWindow: 1000,
|
||||
},
|
||||
{
|
||||
id: "gpt-test-a",
|
||||
name: "A-Model",
|
||||
provider: "openai",
|
||||
available: false,
|
||||
contextWindow: 8000,
|
||||
},
|
||||
{
|
||||
id: "gpt-test-z",
|
||||
name: "gpt-test-z",
|
||||
provider: "openai",
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -650,6 +655,7 @@ describe("gateway server models + voicewake", () => {
|
||||
id: "gpt-test-z",
|
||||
name: "gpt-test-z",
|
||||
provider: "openai",
|
||||
available: false,
|
||||
},
|
||||
]);
|
||||
},
|
||||
@@ -689,11 +695,13 @@ describe("gateway server models + voicewake", () => {
|
||||
id: "claude-test-a",
|
||||
name: "claude-test-a",
|
||||
provider: "anthropic",
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-test-z",
|
||||
name: "gpt-test-z",
|
||||
provider: "openai",
|
||||
available: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -710,6 +718,7 @@ describe("gateway server models + voicewake", () => {
|
||||
id: "not-in-catalog",
|
||||
name: "not-in-catalog",
|
||||
provider: "openai",
|
||||
available: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user