diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index b307129ef639..0ed9e98a4dba 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -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") diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt index 04936f0e0c4e..16c5ced25467 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt @@ -304,8 +304,10 @@ private fun providerCommandSubtitle( models: List, ): 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" } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt index b1dcd2d18150..9e338e69a946 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt @@ -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, models: List, ): List { 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): List "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, + models: List, +): 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): 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): List>> = 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) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt index 002fcacc50e3..e42e4acc7d9d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt @@ -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 = 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 diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/ProviderModelStatusTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/ProviderModelStatusTest.kt index cac110d3a2d3..33b60579cec6 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/ProviderModelStatusTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/ProviderModelStatusTest.kt @@ -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, + ) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt index 8d2f34827a45..12bed0a0ddc6 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt @@ -92,6 +92,21 @@ class ShellScreenLogicTest { assertEquals(emptyList(), 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()) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index f248d698a3cf..a76a0cf300f9 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 } diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index 02887bf97a20..492d121b3070 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -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()), }, diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index ee63b62efee5..ea5f92926f25 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -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> = Object.fromEntries( diff --git a/src/agents/model-catalog-browse.ts b/src/agents/model-catalog-browse.ts index 56bd9993eaa1..3d5559206b48 100644 --- a/src/agents/model-catalog-browse.ts +++ b/src/agents/model-catalog-browse.ts @@ -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; timeoutMs?: number; onTimeout?: (timeoutMs: number) => void; -}): Promise { +}): 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; + timeoutMs?: number; + onTimeout?: (timeoutMs: number) => void; +}): Promise { + return (await loadModelCatalogForBrowseWithState(params)).catalog; +} diff --git a/src/agents/model-catalog-visibility.ts b/src/agents/model-catalog-visibility.ts index 91a45cdac6e4..0bacc07b86af 100644 --- a/src/agents/model-catalog-visibility.ts +++ b/src/agents/model-catalog-visibility.ts @@ -14,7 +14,7 @@ import { } from "./model-visibility-policy.js"; type ModelCatalogVisibilityView = "default" | "configured" | "all"; -type ProviderAuthChecker = (provider: string, modelApi?: string) => boolean | Promise; +export type ProviderAuthChecker = (provider: string, modelApi?: string) => boolean | Promise; 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 { @@ -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); } } diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index da916e43d513..d24bc66a2bd0 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -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>(); + 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 { + 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 { + 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; -}): 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, + }), + }; } diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index e5cc56023a59..155d3ba63a86 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -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(); + 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 () => { diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index 87c03c3935fe..0bad7cac2247 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -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, }, ], });