fix(android): clarify provider attention state

This commit is contained in:
Tosko4
2026-06-05 01:34:29 +02:00
committed by Ayaan Zaidi
parent 12a569109b
commit 8b66003a0b
14 changed files with 528 additions and 46 deletions

View File

@@ -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")

View File

@@ -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"
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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,
)
}

View File

@@ -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())

View File

@@ -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
}

View File

@@ -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()),
},

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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,
}),
};
}

View File

@@ -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 () => {

View File

@@ -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,
},
],
});