Compare commits

...
Author SHA1 Message Date
Bailey Dixon b60c5d9eeb Merge remote-tracking branch 'origin/dev' into codex/pr-425-integration
# Conflicts:
#	CHANGELOG.md
2026-08-24 21:27:03 -04:00
Bailey Dixon 9e201e54d7 Merge pull request #393 from Codename-11/feature/provider-usage
feat: add provider-aware usage and limits (salvages #384)
2026-08-24 21:13:20 -04:00
Bailey Dixon 8bb503eb6d fix(android): use appearance shape for usage card 2026-08-24 21:03:18 -04:00
Bailey Dixon c223dc690d Merge remote-tracking branch 'origin/dev' into codex/pr-393-integration
# Conflicts:
#	CHANGELOG.md
#	app/src/main/res/values-b+pt+BR/strings.xml
#	app/src/main/res/values-b+zh+Hans/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values-ru/strings.xml
#	docs/decisions.md
#	docs/localization-status.json
2026-08-24 20:51:41 -04:00
Bailey Dixon 44e3bb75cd Merge pull request #421 from Codename-11/fix/pre-release-install-site
feat: align pre-release install and onboarding surfaces
2026-08-24 20:45:36 -04:00
Bailey Dixon 4834fcbdf5 fix(android): settle orphaned gateway composer state 2026-08-24 20:43:53 -04:00
Bailey Dixon da7ea8ffe0 feat: clarify Relay usage capabilities
Mark enhanced provider responses explicitly and explain in Settings which usage features require the matching Relay plugin.
2026-08-21 14:05:45 -04:00
Bailey Dixon 5c5c55d982 feat: support credential-aware provider usage
Resolve active Codex pool credentials from live Dashboard sessions, retain a secret-free standalone Relay fallback, and expose structured Nous balances.

Polish the Android Usage & limits surface with non-blocking skeletons, refresh controls, provider-specific landing visibility, and localized balance/status presentation.
2026-08-21 11:58:58 -04:00
Bailey Dixon 0208098687 feat: generalize provider usage settings 2026-08-21 10:11:03 -04:00
ophirhan 34fc4c4693 feat(android): show OpenCode Go subscription usage in Settings
Add an inline Settings card that displays the OpenCode Go subscription quota across its 5-hour (rolling), weekly, and monthly windows as progress bars with dollars used, the window cap, and a resets-in countdown.

The phone never sees the OpenCode Go API key. The relay host proxies GET /usage/opencode (bearer-authenticated to a paired session), reading OPENCODE_GO_API_KEY from the host .env and returning {usage, limits}. Hosts without OpenCode Go configured return 404, which the client renders as a quiet "not available" state instead of an error.

Verified: relay route + auth, upstream data shape, and 5 client unit tests; lint clean.
(cherry picked from commit 48251d0a36)
2026-08-21 08:56:25 -04:00
37 changed files with 3120 additions and 20 deletions
+2 -2
View File
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Added
- **Android and Relay add top-level provider usage and limit settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden Settings presentation modes plus per-provider landing-page visibility. The authenticated Relay Dashboard plugin resolves the active Codex credential directly from the live session; paired standalone clients retain an explicitly enabled Relay fallback. The UI identifies Relay-plugin-enhanced data and explains which capabilities require the matching plugin. Provider credentials remain host-side.
- **Desktop releases now include a Linux ARM64 CLI artifact.** The one-line installer, updater, checksums, release publication, architecture validation, and platform documentation all recognize the same `linux-arm64` binary.
- **The public site now shows the real Windows CLI UI and guides each surface through first use.** Deterministic public-safe screenshots cover connection, host access, activity, computer control, and updates; Android and CLI paths now carry users from install through prerequisites, pairing, verification, and a concrete first-success action before the long-form reference material.
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
@@ -21,9 +22,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **Android chats no longer retain a stale busy composer.** A completed Gateway bubble settles automatically when its exact session has no live or detached turn, new-chat navigation clears stale visible ownership, and Stop remains an immediate escape hatch. (#416, #418)
- **README and Google Play onboarding now match the Dashboard-first product path.** Public setup copy names the two separate Dashboard QR actions, treats the API server as an advanced fallback, explains the encouraged Hermes-Relay extension without implying Play includes Device Control, and ships one current deterministic Android screenshot set.
- **Desktop install and update discovery remains reliable in a multi-surface release repository.** Every resolver paginates GitHub releases before choosing the SemVer maximum, Windows cooperative updates clean their released backup, unsigned preview installers retain the normal SmartScreen warning, and release smoke tests preserve real exit codes.
- **Android fresh chats no longer inherit a stale busy composer.** New-chat navigation settles visible streaming ownership even when a Gateway turn has already lost its live handle, and Stop remains an immediate escape hatch after the terminal bubble has settled. (#416, #418)
- **The Android Sphere remains gently animated while visibly idle.** New chats and the ambient Sphere behind messages now use a low-cost layer breath, while hidden/backgrounded and motion-disabled surfaces stay still and active agent/voice states retain their full procedural animation.
- **Android retries Windows-hosted `MEDIA:` attachments through Relay's by-path route.** A document deferred on cellular no longer treats `C:\...` as an opaque media token and reports it as expired.
- **Relay profile discovery follows `HERMES_HOME` by default.** Custom Hermes installations surface their real default profile and persist Relay sessions beside the active config while retaining the explicit `RELAY_HERMES_CONFIG` override.
@@ -82,7 +83,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Android screen-on idle no longer continuously redraws the ASCII sphere.** Idle holds a stable frame while thinking, streaming, and voice states retain full-rate motion; inactive voice waveforms and closed session drawers also stop their frame loops.
- **Android capture and audio effects release power-sensitive resources at their actual lifecycle boundaries.** Screen capture attaches its MediaProjection surface only for a requested frame, unattended Bridge wake locks release when the command finishes, and barge-in AEC/noise suppression attach to the microphone capture session instead of playback.
- **Experimental wake-word listening reuses its PCM normalization buffer.** Continuous opt-in listening no longer allocates a new float frame for every inference call.
## [1.10.0] - 2026-08-18
### Added
@@ -0,0 +1,63 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
enum class ProviderUsageLandingMode(val storedValue: String) {
Summary("summary"),
Expanded("expanded"),
Hidden("hidden"),
;
companion object {
fun fromStoredValue(value: String?): ProviderUsageLandingMode =
entries.firstOrNull { it.storedValue == value } ?: Summary
}
}
data class ProviderUsagePreferences(
val landingMode: ProviderUsageLandingMode = ProviderUsageLandingMode.Summary,
val visibleProviders: Set<String> = DEFAULT_VISIBLE_PROVIDERS,
) {
companion object {
val DEFAULT_VISIBLE_PROVIDERS = setOf("openai-codex", "nous", "opencode-go")
}
}
class ProviderUsagePreferencesRepository(private val dataStore: DataStore<Preferences>) {
constructor(context: Context) : this(context.relayDataStore)
companion object {
internal val KEY_LANDING_MODE = stringPreferencesKey("provider_usage_landing_mode")
internal val KEY_VISIBLE_PROVIDERS = stringSetPreferencesKey("provider_usage_visible_providers")
}
val preferences: Flow<ProviderUsagePreferences> = dataStore.data
.map { prefs ->
ProviderUsagePreferences(
landingMode = ProviderUsageLandingMode.fromStoredValue(prefs[KEY_LANDING_MODE]),
visibleProviders = prefs[KEY_VISIBLE_PROVIDERS]
?: ProviderUsagePreferences.DEFAULT_VISIBLE_PROVIDERS,
)
}
.distinctUntilChanged()
suspend fun setLandingMode(mode: ProviderUsageLandingMode) {
dataStore.edit { it[KEY_LANDING_MODE] = mode.storedValue }
}
suspend fun setProviderVisible(providerId: String, visible: Boolean) {
dataStore.edit { prefs ->
val current = prefs[KEY_VISIBLE_PROVIDERS]
?: ProviderUsagePreferences.DEFAULT_VISIBLE_PROVIDERS
prefs[KEY_VISIBLE_PROVIDERS] = if (visible) current + providerId else current - providerId
}
}
}
@@ -9,6 +9,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
@@ -1444,4 +1445,86 @@ class RelayHttpClient(
val value = header?.trim()?.lowercase() ?: return false
return value == "1" || value == "true"
}
/** Provider-neutral compatibility fetch for gateways without `account.usage`. */
suspend fun fetchProviderUsage(
profile: String? = null,
sessionId: String? = null,
): Result<ProviderUsageResponse?> =
withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) {
return@withContext Result.success(null)
}
val sessionToken = sessionTokenProvider()
if (sessionToken.isNullOrBlank()) {
return@withContext Result.success(null)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val url = "$httpBase/usage/providers".toHttpUrlOrNull()
?.newBuilder()
?.apply {
profile?.trim()?.takeIf { it.isNotEmpty() }?.let {
addQueryParameter("profile", it)
}
sessionId?.trim()?.takeIf { it.isNotEmpty() }?.let {
addQueryParameter("session_id", it)
}
}
?.build()
?: return@withContext Result.failure(
IllegalArgumentException("Invalid relay URL: $httpBase")
)
val request = Request.Builder()
.url(url)
.get()
.header("Authorization", "Bearer $sessionToken")
.header("Accept", "application/json")
.build()
try {
okHttpClient.newCall(request).execute().use { response ->
if (response.code == 404) {
// Older or operator-disabled hosts simply do not expose
// account usage. This is capability absence, not an error.
return@withContext Result.success(null)
}
if (!response.isSuccessful) {
val reason = when (response.code) {
401, 403 -> "Unauthorized — re-pair with the relay"
502 -> "Provider usage upstream error (HTTP ${response.code})"
in 500..599 -> "Relay error (HTTP ${response.code})"
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
}
return@withContext Result.failure(IOException(reason))
}
val body = response.body?.string().orEmpty()
if (body.isBlank()) {
return@withContext Result.failure(IOException("Empty response body"))
}
val parsed = runCatching {
sessionsJson.decodeFromString(
ProviderUsageResponse.serializer(),
body,
)
}.getOrElse {
Log.w(TAG, "fetchProviderUsage parse error: ${it.message}")
return@withContext Result.failure(IOException("Unrecognized usage payload"))
}
Result.success(parsed)
}
} catch (e: IOException) {
Log.w(TAG, "fetchProviderUsage failed: ${e.message}")
Result.failure(IOException("Relay unreachable: ${e.message ?: "IO error"}"))
} catch (e: Exception) {
Log.w(TAG, "fetchProviderUsage unexpected error: ${e.message}")
Result.failure(e)
}
}
}
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.network.upstream
import android.content.Context
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.shutdownOffMainThread
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.MessageListResponse
import com.hermesandroid.relay.network.upstream.models.SessionItem
@@ -448,6 +449,25 @@ class DashboardApiClient(
*/
suspend fun getConfig(): Result<JsonObject> = getJsonObject("/api/config")
suspend fun getProviderUsage(
profile: String? = null,
sessionId: String? = null,
): Result<ProviderUsageResponse?> {
val query = buildList {
profile?.trim()?.takeIf { it.isNotEmpty() }?.let {
add("profile=${queryValue(it)}")
}
sessionId?.trim()?.takeIf { it.isNotEmpty() }?.let {
add("session_id=${queryValue(it)}")
}
}
val suffix = query.joinToString(prefix = if (query.isEmpty()) "" else "?", separator = "&")
return getJsonObject("/api/plugins/hermes-relay/provider-usage$suffix")
.mapCatching { root ->
json.decodeFromJsonElement(ProviderUsageResponse.serializer(), root)
}
}
/**
* The config SCHEMA: `{fields: {<dot.path>: {type, description, category,
* options?}}, category_order: [...]}`. Describes how to render each field;
@@ -797,6 +797,11 @@ class GatewayChatClient(
*/
fun hasActiveTurn(): Boolean = activeTurn?.ended == false || backgroundTurns.isNotEmpty()
/** True only when [storedId] still owns a foreground or deliberately detached turn. */
fun hasActiveTurnForSession(storedId: String): Boolean =
(activeTurn?.ended == false && storedSessionId == storedId) ||
backgroundTurns.values.any { it.storedSessionId == storedId }
/** Live id to persist beside a durable stored id while a turn is active. */
fun currentLiveSessionId(storedId: String): String? =
liveSessionId?.takeIf { storedSessionId == storedId }
@@ -1426,6 +1431,21 @@ class GatewayChatClient(
.onSuccess { commandsCatalogCache = it }
}
/**
* Provider-neutral account limits owned by upstream Hermes. Current hosts
* may not expose this additive method yet; callers should treat JSON-RPC
* method-not-found as capability absence and use the optional Relay
* compatibility surface when paired.
*/
suspend fun providerUsage(): Result<JsonObject> {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
return rpc("account.usage", JsonObject(emptyMap()))
}
/**
* Create a schedule through upstream's authenticated `cron.manage` RPC.
* No Relay scheduler or compatibility endpoint is involved.
@@ -0,0 +1,89 @@
package com.hermesandroid.relay.network.usage
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ProviderUsageResponse(
@SerialName("schema_version") val schemaVersion: Int = 1,
@SerialName("fetched_at") val fetchedAt: String? = null,
val capabilities: Set<String> = emptySet(),
val providers: List<ProviderUsageProvider> = emptyList(),
) {
val relayEnhanced: Boolean
get() = capabilities.containsAll(RELAY_ENHANCED_CAPABILITIES)
companion object {
val RELAY_ENHANCED_CAPABILITIES = setOf(
"credential_pools",
"structured_balances",
"opencode_go",
)
}
}
@Serializable
data class ProviderUsageProvider(
val id: String,
@SerialName("display_name") val displayName: String,
val status: String,
val source: String? = null,
@SerialName("fetched_at") val fetchedAt: String? = null,
val plan: String? = null,
val windows: List<ProviderUsageWindow> = emptyList(),
val details: List<String> = emptyList(),
val balances: List<ProviderUsageBalance> = emptyList(),
@SerialName("renews_at") val renewsAt: String? = null,
@SerialName("action_url") val actionUrl: String? = null,
val credentials: List<ProviderUsageCredential> = emptyList(),
@SerialName("active_credential_id") val activeCredentialId: String? = null,
@SerialName("active_credential_state") val activeCredentialState: String = "unknown",
@SerialName("active_observed_at") val activeObservedAt: String? = null,
val message: String? = null,
) {
val available: Boolean get() = status == STATUS_AVAILABLE
companion object {
const val STATUS_AVAILABLE = "available"
const val STATUS_NOT_CONFIGURED = "not_configured"
const val STATUS_UNAVAILABLE = "unavailable"
}
}
@Serializable
data class ProviderUsageBalance(
val id: String,
val label: String,
val amount: Double,
val currency: String = "USD",
)
@Serializable
data class ProviderUsageCredential(
val id: String,
val label: String,
val active: Boolean = false,
val status: String,
@SerialName("pool_status") val poolStatus: String? = null,
@SerialName("last_status_at") val lastStatusAt: String? = null,
@SerialName("reset_at") val resetAt: String? = null,
val plan: String? = null,
val windows: List<ProviderUsageWindow> = emptyList(),
val details: List<String> = emptyList(),
val message: String? = null,
) {
companion object {
const val STATUS_AVAILABLE = "available"
const val STATUS_AT_LIMIT = "at_limit"
const val STATUS_UNAVAILABLE = "unavailable"
}
}
@Serializable
data class ProviderUsageWindow(
val id: String,
val label: String,
@SerialName("used_percent") val usedPercent: Double? = null,
@SerialName("reset_at") val resetAt: String? = null,
val detail: String? = null,
)
@@ -0,0 +1,46 @@
package com.hermesandroid.relay.network.usage
import com.hermesandroid.relay.network.relay.RelayHttpClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
/** Relay-enhanced usage with an upstream fallback for hosts without Relay support. */
class ProviderUsageRepository(
private val gatewayClientProvider: () -> GatewayChatClient?,
private val dashboardClientProvider: () -> DashboardApiClient? = { null },
private val relayHttpClient: RelayHttpClient,
private val profileProvider: () -> String? = { null },
private val sessionProvider: () -> String? = { null },
) {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
explicitNulls = false
}
suspend fun fetch(): Result<ProviderUsageResponse?> {
val profile = profileProvider()
val session = sessionProvider()
val dashboard = dashboardClientProvider()
if (dashboard != null) {
val enhanced = dashboard.getProviderUsage(profile, session)
if (enhanced.isSuccess && enhanced.getOrNull() != null) return enhanced
}
val relay = relayHttpClient.fetchProviderUsage(
profile = profile,
sessionId = session,
)
if (relay.isSuccess && relay.getOrNull() != null) return relay
val gateway = gatewayClientProvider()
if (gateway != null) {
val upstream = gateway.providerUsage()
.mapCatching { json.decodeFromJsonElement<ProviderUsageResponse>(it) }
if (upstream.isSuccess) return upstream
}
return relay
}
}
@@ -170,6 +170,7 @@ import com.hermesandroid.relay.ui.screens.PermissionsStatusScreen
import com.hermesandroid.relay.ui.screens.ProfileInspectorScreen
import com.hermesandroid.relay.ui.screens.RealtimeVoiceTestScreen
import com.hermesandroid.relay.ui.screens.SettingsScreen
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
import com.hermesandroid.relay.ui.screens.PluginsScreen
import com.hermesandroid.relay.ui.screens.PluginPageScreen
import com.hermesandroid.relay.ui.screens.TerminalScreen
@@ -530,6 +531,7 @@ sealed class Screen(
// the plural `ConnectionsSettings` subpage. See `ConnectionsSettings`
// above for the surviving route.)
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
data object ProviderUsage : Screen("settings/usage", "Usage & limits", Icons.Filled.Settings)
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
data object CustomTheme : Screen("settings/appearance/custom-theme", "Custom", Icons.Filled.Settings)
@@ -2387,6 +2389,9 @@ fun RelayApp() {
onNavigateToManage = {
navController.navigate(Screen.Manage.route)
},
onNavigateToProviderUsage = {
navController.navigate(Screen.ProviderUsage.route)
},
onNavigateToPlugins = {
navController.navigate(Screen.Plugins.route)
},
@@ -2445,6 +2450,13 @@ fun RelayApp() {
},
)
}
composable(Screen.ProviderUsage.route) {
UsageLimitsScreen(
connectionViewModel = connectionViewModel,
chatViewModel = chatViewModel,
onBack = { navController.popBackStack() },
)
}
composable(Screen.Plugins.route) {
PluginsScreen(
viewModel = pluginsViewModel,
@@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.NewReleases
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -70,6 +71,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -96,11 +98,17 @@ import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.ProviderUsageLandingMode
import com.hermesandroid.relay.data.ProviderUsagePreferences
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.ui.components.AgentAvatarFace
import com.hermesandroid.relay.ui.components.AgentInfoSheet
import com.hermesandroid.relay.ui.components.LocalAgentIconPath
import com.hermesandroid.relay.ui.components.ProfileInspectorCard
import com.hermesandroid.relay.ui.components.RelaySkeletonLine
import com.hermesandroid.relay.ui.components.pet.LocalPetCompanionCoordinator
import com.hermesandroid.relay.ui.components.pet.petObstacleSurface
import com.hermesandroid.relay.ui.components.pet.petPerchSurface
@@ -114,6 +122,7 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.delay
private const val SETTINGS_PET_SURFACE_ROUTE = "settings"
private val SETTINGS_PET_SURFACE_ROUTES = setOf(SETTINGS_PET_SURFACE_ROUTE)
@@ -170,6 +179,7 @@ fun SettingsScreen(
// expandable sections, so there's nothing left to link to twice.
onNavigateToConnections: () -> Unit,
onNavigateToManage: () -> Unit,
onNavigateToProviderUsage: () -> Unit,
onNavigateToPlugins: () -> Unit,
onNavigateToChatSettings: () -> Unit,
onNavigateToTerminal: () -> Unit,
@@ -200,9 +210,57 @@ fun SettingsScreen(
val isDarkTheme = LocalBrand.current.isDark
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val providerUsagePreferencesRepository = remember(context) {
ProviderUsagePreferencesRepository(context)
}
val providerUsagePreferences by providerUsagePreferencesRepository.preferences.collectAsState(
initial = ProviderUsagePreferences(),
)
val providerUsageRepository = remember(connectionViewModel) {
ProviderUsageRepository(
gatewayClientProvider = connectionViewModel::activeGatewayChatClient,
dashboardClientProvider = {
connectionViewModel.activeDashboardUrl()?.let(
connectionViewModel::dashboardClientForActive,
)
},
relayHttpClient = connectionViewModel.relayHttpClient,
profileProvider = { connectionViewModel.selectedProfile.value?.name },
sessionProvider = { chatViewModel.currentSessionId.value },
)
}
var providerUsageResponse by remember { mutableStateOf<ProviderUsageResponse?>(null) }
var providerUsageLoaded by remember { mutableStateOf(false) }
var providerUsageRefreshing by remember { mutableStateOf(false) }
var providerUsageRefreshKey by remember { mutableIntStateOf(0) }
LaunchedEffect(
activeConnection?.id,
selectedProfile?.name,
currentSessionId,
providerUsagePreferences.landingMode,
providerUsageRefreshKey,
) {
if (providerUsagePreferences.landingMode == ProviderUsageLandingMode.Hidden) {
providerUsageResponse = null
providerUsageLoaded = true
} else {
if (providerUsageResponse == null) providerUsageLoaded = false
providerUsageRefreshing = providerUsageResponse != null
providerUsageRepository.fetch().getOrNull()?.let { providerUsageResponse = it }
providerUsageLoaded = true
providerUsageRefreshing = false
}
}
LaunchedEffect(providerUsagePreferences.landingMode) {
while (providerUsagePreferences.landingMode != ProviderUsageLandingMode.Hidden) {
delay(300_000)
providerUsageRefreshKey++
}
}
// Active Agent card inputs — personality + profile drive the title,
// ring-accent, and subtitle.
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
@@ -477,6 +535,16 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
ProviderUsageLandingCard(
response = providerUsageResponse,
loaded = providerUsageLoaded,
refreshing = providerUsageRefreshing,
preferences = providerUsagePreferences,
onDisplay = onNavigateToProviderUsage,
onRefresh = { providerUsageRefreshKey++ },
isDarkTheme = isDarkTheme,
)
SettingsSectionHeader(stringResource(R.string.settings_hermes))
SettingsCategoryRow(
@@ -1257,6 +1325,135 @@ private fun SettingsStatusPill(pill: SettingsStatusPillModel) {
}
}
@Composable
private fun ProviderUsageLandingCard(
response: ProviderUsageResponse?,
loaded: Boolean,
refreshing: Boolean,
preferences: ProviderUsagePreferences,
onDisplay: () -> Unit,
onRefresh: () -> Unit,
isDarkTheme: Boolean,
) {
val providers = response?.providers
?.filter { it.available && it.id in preferences.visibleProviders }
.orEmpty()
Card(
modifier = Modifier
.settingsPetSurface("settings-card:provider-usage")
.fillMaxWidth()
.gradientBorder(
shape = appearanceRoundedCornerShape(12.dp),
isDarkTheme = isDarkTheme,
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Analytics,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.provider_usage_title),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(
when (response?.relayEnhanced) {
true -> R.string.provider_usage_settings_desc_relay
false -> R.string.provider_usage_settings_desc_basic
null -> R.string.provider_usage_settings_desc
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onRefresh, enabled = !refreshing) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.provider_usage_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onDisplay) {
Text(stringResource(R.string.provider_usage_customize))
}
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
when {
preferences.landingMode == ProviderUsageLandingMode.Hidden -> {
Text(
text = stringResource(R.string.provider_usage_hidden_hint),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
!loaded -> {
ProviderUsageSkeleton(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
providers.isEmpty() -> {
Text(
text = stringResource(R.string.provider_usage_not_available_compact),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> providers.forEachIndexed { index, provider ->
if (index > 0) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
ProviderUsageContent(
provider = provider,
detailed = preferences.landingMode == ProviderUsageLandingMode.Expanded,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
}
}
}
}
@Composable
private fun ProviderUsageSkeleton(modifier: Modifier = Modifier) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
RelaySkeletonLine(width = 112.dp, height = 16.dp)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
RelaySkeletonLine(width = 86.dp)
RelaySkeletonLine(width = 58.dp)
}
RelaySkeletonLine(width = 260.dp, height = 6.dp)
RelaySkeletonLine(width = 92.dp, height = 10.dp)
}
}
@Composable
private fun SettingsSectionHeader(
label: String,
@@ -0,0 +1,700 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ProviderUsageLandingMode
import com.hermesandroid.relay.data.ProviderUsagePreferences
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
import com.hermesandroid.relay.network.usage.ProviderUsageProvider
import com.hermesandroid.relay.network.usage.ProviderUsageCredential
import com.hermesandroid.relay.network.usage.ProviderUsageBalance
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import com.hermesandroid.relay.network.usage.ProviderUsageWindow
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.ui.components.RelaySkeletonLine
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private sealed interface UsageLoadState {
data object Loading : UsageLoadState
data object Unsupported : UsageLoadState
data class Loaded(val response: ProviderUsageResponse) : UsageLoadState
data object Error : UsageLoadState
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UsageLimitsScreen(
connectionViewModel: ConnectionViewModel,
chatViewModel: ChatViewModel,
onBack: () -> Unit,
) {
val context = androidx.compose.ui.platform.LocalContext.current
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val preferencesRepository = remember(context) { ProviderUsagePreferencesRepository(context) }
val preferences by preferencesRepository.preferences.collectAsState(
initial = ProviderUsagePreferences(),
)
val repository = remember(connectionViewModel) {
ProviderUsageRepository(
gatewayClientProvider = connectionViewModel::activeGatewayChatClient,
dashboardClientProvider = {
connectionViewModel.activeDashboardUrl()?.let(
connectionViewModel::dashboardClientForActive,
)
},
relayHttpClient = connectionViewModel.relayHttpClient,
profileProvider = { connectionViewModel.selectedProfile.value?.name },
sessionProvider = { chatViewModel.currentSessionId.value },
)
}
var refreshKey by remember { mutableIntStateOf(0) }
var state by remember { mutableStateOf<UsageLoadState>(UsageLoadState.Loading) }
var refreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(activeConnection?.id, selectedProfile?.name, currentSessionId, refreshKey) {
val hadContent = state is UsageLoadState.Loaded
if (!hadContent) state = UsageLoadState.Loading else refreshing = true
val next = repository.fetch().fold(
onSuccess = { result ->
result?.let(UsageLoadState::Loaded) ?: UsageLoadState.Unsupported
},
onFailure = { UsageLoadState.Error },
)
if (!hadContent || next is UsageLoadState.Loaded) state = next
refreshing = false
}
LaunchedEffect(Unit) {
while (true) {
delay(300_000)
refreshKey++
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.provider_usage_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.provider_usage_back),
tint = MaterialTheme.colorScheme.primary,
)
}
},
actions = {
IconButton(onClick = { refreshKey++ }, enabled = !refreshing) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.provider_usage_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = activeConnection?.label ?: stringResource(R.string.settings_no_connection),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.provider_usage_intro),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
when (val current = state) {
UsageLoadState.Loading -> ProviderUsageLoading()
UsageLoadState.Unsupported -> ProviderUsageMessage(
text = stringResource(R.string.provider_usage_not_available),
)
UsageLoadState.Error -> ProviderUsageError(onRetry = { refreshKey++ })
is UsageLoadState.Loaded -> {
ProviderUsageCapabilityNotice(relayEnhanced = current.response.relayEnhanced)
val providers = current.response.providers
if (providers.none { it.available }) {
ProviderUsageMessage(
text = stringResource(R.string.provider_usage_none_configured),
)
}
providers.forEach { provider ->
ProviderUsageCard(
provider = provider,
detailed = true,
)
}
}
}
ProviderUsageDisplaySettings(
preferences = preferences,
providers = (state as? UsageLoadState.Loaded)?.response?.providers.orEmpty(),
onModeChanged = { mode ->
scope.launch { preferencesRepository.setLandingMode(mode) }
},
onProviderVisibilityChanged = { providerId, visible ->
scope.launch {
preferencesRepository.setProviderVisible(providerId, visible)
}
},
)
}
}
}
@Composable
private fun ProviderUsageCapabilityNotice(relayEnhanced: Boolean) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = stringResource(
if (relayEnhanced) R.string.provider_usage_capability_relay_title
else R.string.provider_usage_capability_basic_title,
),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(
if (relayEnhanced) R.string.provider_usage_capability_relay_body
else R.string.provider_usage_capability_basic_body,
),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@Composable
private fun ProviderUsageLoading() {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(2) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
RelaySkeletonLine(width = 112.dp, height = 18.dp)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
RelaySkeletonLine(width = 92.dp)
RelaySkeletonLine(width = 62.dp)
}
RelaySkeletonLine(width = 280.dp, height = 6.dp)
RelaySkeletonLine(width = 98.dp, height = 10.dp)
}
}
}
}
}
@Composable
private fun ProviderUsageMessage(text: String) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Text(
text = text,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ProviderUsageError(onRetry: () -> Unit) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.provider_usage_error),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onRetry) {
Text(stringResource(R.string.provider_usage_retry))
}
}
}
}
@Composable
fun ProviderUsageCard(
provider: ProviderUsageProvider,
detailed: Boolean,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
ProviderUsageContent(
provider = provider,
detailed = detailed,
modifier = Modifier.padding(16.dp),
)
}
}
@Composable
fun ProviderUsageContent(
provider: ProviderUsageProvider,
detailed: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = provider.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
provider.plan?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (!provider.available) {
Text(
text = providerUnavailableText(provider),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
if (provider.balances.isNotEmpty()) {
ProviderBalanceUsage(provider, detailed)
} else if (provider.credentials.isNotEmpty()) {
val shownCredentials = if (detailed) {
provider.credentials
} else {
provider.credentials.filter { it.active }.take(1)
}
if (shownCredentials.isEmpty()) {
Text(
text = stringResource(R.string.provider_usage_active_unknown),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
shownCredentials.forEach { credential ->
ProviderCredentialUsage(credential, detailed)
}
}
} else {
val windows = if (detailed) provider.windows else provider.windows.take(1)
windows.forEach { ProviderUsageWindowRow(it) }
}
if (detailed && provider.credentials.isEmpty() && provider.balances.isEmpty()) {
provider.details.forEach { detail ->
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ProviderBalanceUsage(
provider: ProviderUsageProvider,
detailed: Boolean,
) {
val uriHandler = LocalUriHandler.current
val total = provider.balances.firstOrNull { it.id == "total" }
?: provider.balances.first()
val supporting = provider.balances.filterNot { it.id == total.id }
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = formatBalance(total),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = total.label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (detailed) {
supporting.forEach { balance ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = balance.label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = formatBalance(balance),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
}
}
}
formatRenewal(provider.renewsAt)?.let { renewal ->
Text(
text = stringResource(R.string.provider_usage_renews_on, renewal),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (detailed && !provider.actionUrl.isNullOrBlank()) {
TextButton(onClick = { uriHandler.openUri(provider.actionUrl) }) {
Text(stringResource(R.string.provider_usage_manage_credits))
}
}
if (detailed) {
provider.details.forEach { detail ->
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@Composable
private fun ProviderCredentialUsage(
credential: ProviderUsageCredential,
detailed: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = credential.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (credential.active) FontWeight.SemiBold else FontWeight.Normal,
)
Text(
text = when {
credential.active && credential.status == ProviderUsageCredential.STATUS_AVAILABLE ->
stringResource(R.string.provider_usage_active_available)
credential.active && credential.status == ProviderUsageCredential.STATUS_AT_LIMIT ->
stringResource(R.string.provider_usage_active_at_limit)
credential.active -> stringResource(R.string.provider_usage_active)
credential.status == ProviderUsageCredential.STATUS_AVAILABLE ->
stringResource(R.string.provider_usage_available)
credential.status == ProviderUsageCredential.STATUS_AT_LIMIT ->
stringResource(R.string.provider_usage_at_limit)
else -> stringResource(R.string.provider_usage_unavailable_status)
},
style = MaterialTheme.typography.labelSmall,
color = when (credential.status) {
ProviderUsageCredential.STATUS_AT_LIMIT -> MaterialTheme.colorScheme.error
ProviderUsageCredential.STATUS_AVAILABLE -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
val windows = if (detailed) credential.windows else credential.windows.take(1)
windows.forEach { ProviderUsageWindowRow(it) }
if (detailed) {
credential.details.forEach { detail ->
Text(
text = detail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ProviderUsageWindowRow(window: ProviderUsageWindow) {
var now by remember { mutableStateOf(Instant.now()) }
LaunchedEffect(window.resetAt) {
while (window.resetAt != null) {
delay(60_000)
now = Instant.now()
}
}
val percent = window.usedPercent?.coerceIn(0.0, 100.0)
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(window.label, style = MaterialTheme.typography.labelLarge)
Text(
text = percent?.let { stringResource(R.string.provider_usage_percent, it.toInt()) }
?: window.detail.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (percent != null) {
LinearProgressIndicator(
progress = { (percent / 100.0).toFloat() },
modifier = Modifier.fillMaxWidth().height(6.dp),
color = when {
percent >= 90 -> MaterialTheme.colorScheme.error
percent >= 75 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.primary
},
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
}
formatReset(window.resetAt, now)?.let { reset ->
Text(
text = stringResource(R.string.provider_usage_resets, reset),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (percent != null && !window.detail.isNullOrBlank()) {
Text(
text = window.detail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun ProviderUsageDisplaySettings(
preferences: ProviderUsagePreferences,
providers: List<ProviderUsageProvider>,
onModeChanged: (ProviderUsageLandingMode) -> Unit,
onProviderVisibilityChanged: (String, Boolean) -> Unit,
) {
Text(
text = stringResource(R.string.provider_usage_display_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(R.string.provider_usage_display_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val modes = ProviderUsageLandingMode.entries
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
modes.forEachIndexed { index, mode ->
SegmentedButton(
selected = preferences.landingMode == mode,
onClick = { onModeChanged(mode) },
shape = SegmentedButtonDefaults.itemShape(index, modes.size),
) {
Text(
when (mode) {
ProviderUsageLandingMode.Summary -> stringResource(R.string.provider_usage_mode_summary)
ProviderUsageLandingMode.Expanded -> stringResource(R.string.provider_usage_mode_expanded)
ProviderUsageLandingMode.Hidden -> stringResource(R.string.provider_usage_mode_hidden)
}
)
}
}
}
Text(
text = stringResource(R.string.provider_usage_providers_title),
style = MaterialTheme.typography.labelLarge,
)
Text(
text = stringResource(R.string.provider_usage_providers_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val rows = if (providers.isEmpty()) {
listOf(
"openai-codex" to "Codex",
"nous" to "Nous",
"opencode-go" to "OpenCode Go",
)
} else {
providers.map { it.id to it.displayName }
}
rows.forEach { (id, label) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.bodyLarge)
Switch(
checked = id in preferences.visibleProviders,
onCheckedChange = { onProviderVisibilityChanged(id, it) },
)
}
}
}
}
}
@Composable
private fun providerUnavailableText(provider: ProviderUsageProvider): String =
if (provider.status == ProviderUsageProvider.STATUS_NOT_CONFIGURED) {
stringResource(R.string.provider_usage_provider_not_configured)
} else {
stringResource(R.string.provider_usage_provider_unavailable)
}
private fun formatReset(raw: String?, now: Instant): String? = runCatching {
val reset = Instant.parse(raw ?: return null)
val duration = Duration.between(now, reset)
if (duration.isNegative || duration.isZero) return "now"
val days = duration.toDays()
val hours = duration.toHours() % 24
val minutes = duration.toMinutes() % 60
when {
days > 0 -> "${days}d ${hours}h"
hours > 0 -> "${hours}h ${minutes}m"
else -> "${minutes}m"
}
}.getOrNull()
private fun formatBalance(balance: ProviderUsageBalance): String = runCatching {
NumberFormat.getCurrencyInstance().apply {
currency = Currency.getInstance(balance.currency)
}.format(balance.amount)
}.getOrElse { "${balance.amount} ${balance.currency}" }
private fun formatRenewal(raw: String?): String? = runCatching {
val instant = Instant.parse(raw ?: return null)
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault())
.format(instant)
}.getOrNull()
@@ -396,6 +396,7 @@ class ChatViewModel : ViewModel() {
private var firstTokenNotified = false
private var toolHistoryJob: Job? = null
private var gatewayComposerSettlementJob: Job? = null
private var backgroundProcessSessionJob: Job? = null
private var connectionSwitchJob: Job? = null
private var sessionRefreshJob: Job? = null
@@ -3403,6 +3404,8 @@ class ChatViewModel : ViewModel() {
if (this.chatHandler !== chatHandler) {
checkpointStatusJob?.cancel()
checkpointStatusJob = null
gatewayComposerSettlementJob?.cancel()
gatewayComposerSettlementJob = null
}
this.chatHandler = chatHandler
ensureCheckpointObservers()
@@ -3454,6 +3457,26 @@ class ChatViewModel : ViewModel() {
scheduleCheckpointWrite()
}
}
gatewayComposerSettlementJob?.cancel()
gatewayComposerSettlementJob = viewModelScope.launch {
chatHandler.messages.collect { messages ->
val storedSessionId = chatHandler.currentSessionId.value ?: return@collect
val client = gatewayClient ?: return@collect
if (
streamingEndpoint == "gateway" &&
chatHandler.isStreaming.value &&
messages.none { it.isStreaming || it.isThinkingStreaming } &&
!client.hasActiveTurnForSession(storedSessionId)
) {
// A terminal bubble with no matching live or detached
// Gateway owner is an orphaned handler-wide busy bit. Clear
// it without disturbing a different session's active turn.
chatHandler.clearStreamingStatus()
_steerableTurn.value = false
_steerNotice.value = null
}
}
}
}
/**
@@ -4136,6 +4136,45 @@
<string name="bridge_timed_unlimited_warning">Sem limite de inatividade. O acesso continua após inatividade e reconexão até ser encerrado, a chave mestra ser desligada ou a política mudar. Ideal para um dispositivo dedicado.</string>
<string name="bss_screen_access_off_desc">O acesso à tela está desligado. Novo acesso finito usa %1$d minutos ocioso por padrão.</string>
<string name="bss_screen_access_unlimited_desc">Pelo menos um recurso de tela permanece ativo até ser desligado explicitamente.</string>
<string name="provider_usage_title">Uso e limites</string>
<string name="provider_usage_back">Voltar</string>
<string name="provider_usage_refresh">Atualizar uso</string>
<string name="provider_usage_intro">Limites de conta dos provedores configurados nesta conexão do Hermes.</string>
<string name="provider_usage_not_available">Esta conexão Hermes não expõe o uso dos provedores. Atualize o Hermes ou instale/atualize o plugin Relay.</string>
<string name="provider_usage_none_configured">Nenhum provedor visível tem dados de uso da conta disponíveis.</string>
<string name="provider_usage_loading">Carregando uso dos provedores…</string>
<string name="provider_usage_error">Não foi possível carregar o uso dos provedores.</string>
<string name="provider_usage_retry">Tentar novamente</string>
<string name="provider_usage_percent">%1$d%% usado</string>
<string name="provider_usage_resets">Redefine em %1$s</string>
<string name="provider_usage_display_title">Exibição nas Configurações</string>
<string name="provider_usage_display_desc">Escolha como o uso da conta aparece na tela principal de Configurações.</string>
<string name="provider_usage_mode_summary">Resumo</string>
<string name="provider_usage_mode_expanded">Expandido</string>
<string name="provider_usage_mode_hidden">Oculto</string>
<string name="provider_usage_providers_title">Mostrar nas Configurações principais</string>
<string name="provider_usage_providers_desc">Escolha quais cartões de provedores aparecem nas Configurações principais. Todos continuam visíveis aqui.</string>
<string name="provider_usage_settings_desc">Uso da conta e limites dos provedores</string>
<string name="provider_usage_settings_desc_relay">Uso e limites ampliados pelo plugin Relay</string>
<string name="provider_usage_settings_desc_basic">Uso básico do Hermes · Relay adiciona pools e mais</string>
<string name="provider_usage_customize">Exibição</string>
<string name="provider_usage_hidden_hint">Os cartões de uso estão ocultos nas Configurações.</string>
<string name="provider_usage_not_available_compact">O uso dos provedores está indisponível. Atualize o Hermes ou o plugin Relay.</string>
<string name="provider_usage_provider_not_configured">Não configurado neste host</string>
<string name="provider_usage_provider_unavailable">Uso temporariamente indisponível</string>
<string name="provider_usage_active_unknown">Esta sessão ainda não tem uma credencial ativa.</string>
<string name="provider_usage_active_available">Ativa · Disponível</string>
<string name="provider_usage_active_at_limit">Ativa · Limite atingido</string>
<string name="provider_usage_active">Ativa</string>
<string name="provider_usage_available">Disponível</string>
<string name="provider_usage_at_limit">Limite atingido</string>
<string name="provider_usage_unavailable_status">Indisponível</string>
<string name="provider_usage_renews_on">Renova em %1$s</string>
<string name="provider_usage_manage_credits">Gerenciar créditos</string>
<string name="provider_usage_capability_relay_title">Ampliado pelo plugin Relay</string>
<string name="provider_usage_capability_relay_body">Pools de credenciais, saldos estruturados da Nous e OpenCode Go são fornecidos pelo plugin Relay.</string>
<string name="provider_usage_capability_basic_title">Uso básico do Hermes</string>
<string name="provider_usage_capability_basic_body">Instale ou atualize o plugin Relay para pools de credenciais, saldos estruturados da Nous e OpenCode Go.</string>
<string name="custom_theme_title">Personalizado</string>
<string name="custom_theme_entry_summary">Crie e salve seus próprios temas</string>
<string name="custom_theme_your_presets">Seus temas</string>
@@ -4221,6 +4221,45 @@
<string name="bridge_timed_unlimited_warning">无空闲超时。屏幕访问在空闲和重新连接后仍保持,直到结束访问、关闭主开关或更改策略。适合专用设备。</string>
<string name="bss_screen_access_off_desc">屏幕访问已关闭。新的有限访问默认使用 %1$d 分钟空闲限制。</string>
<string name="bss_screen_access_unlimited_desc">至少一项屏幕功能会保持有效,直到明确关闭。</string>
<string name="provider_usage_title">用量和限额</string>
<string name="provider_usage_back">返回</string>
<string name="provider_usage_refresh">刷新用量</string>
<string name="provider_usage_intro">此 Hermes 连接中已配置提供商的账户限额。</string>
<string name="provider_usage_not_available">此 Hermes 连接未提供服务商用量。请更新 Hermes,或安装/更新 Relay 插件。</string>
<string name="provider_usage_none_configured">当前显示的提供商均无可用账户用量。</string>
<string name="provider_usage_loading">正在加载提供商用量…</string>
<string name="provider_usage_error">无法加载提供商用量。</string>
<string name="provider_usage_retry">重试</string>
<string name="provider_usage_percent">已使用 %1$d%%</string>
<string name="provider_usage_resets">%1$s后重置</string>
<string name="provider_usage_display_title">设置页显示</string>
<string name="provider_usage_display_desc">选择账户用量在主设置屏幕中的显示方式。</string>
<string name="provider_usage_mode_summary">摘要</string>
<string name="provider_usage_mode_expanded">展开</string>
<string name="provider_usage_mode_hidden">隐藏</string>
<string name="provider_usage_providers_title">在主设置页显示</string>
<string name="provider_usage_providers_desc">选择要在主设置页显示的提供商卡片。此处仍会显示所有提供商。</string>
<string name="provider_usage_settings_desc">账户用量和提供商限额</string>
<string name="provider_usage_settings_desc_relay">由 Relay 插件增强的用量和限额</string>
<string name="provider_usage_settings_desc_basic">Hermes 基础用量 · Relay 可增加凭据池等功能</string>
<string name="provider_usage_customize">显示</string>
<string name="provider_usage_hidden_hint">设置页已隐藏用量卡片。</string>
<string name="provider_usage_not_available_compact">服务商用量不可用。请更新 Hermes 或 Relay 插件。</string>
<string name="provider_usage_provider_not_configured">此主机未配置</string>
<string name="provider_usage_provider_unavailable">用量暂时不可用</string>
<string name="provider_usage_active_unknown">此会话尚无当前凭据。</string>
<string name="provider_usage_active_available">当前 · 可用</string>
<string name="provider_usage_active_at_limit">当前 · 已达上限</string>
<string name="provider_usage_active">当前</string>
<string name="provider_usage_available">可用</string>
<string name="provider_usage_at_limit">已达上限</string>
<string name="provider_usage_unavailable_status">不可用</string>
<string name="provider_usage_renews_on">续期日期:%1$s</string>
<string name="provider_usage_manage_credits">管理额度</string>
<string name="provider_usage_capability_relay_title">已由 Relay 插件增强</string>
<string name="provider_usage_capability_relay_body">凭据池、结构化 Nous 余额和 OpenCode Go 由 Relay 插件提供。</string>
<string name="provider_usage_capability_basic_title">Hermes 基础用量</string>
<string name="provider_usage_capability_basic_body">安装或更新 Relay 插件即可使用凭据池、结构化 Nous 余额和 OpenCode Go。</string>
<string name="custom_theme_title">自定义</string>
<string name="custom_theme_entry_summary">创建并保存自己的主题</string>
<string name="custom_theme_your_presets">你的预设</string>
+39
View File
@@ -4296,6 +4296,45 @@
<string name="bridge_timed_unlimited_warning">Kein Leerlaufzeitlimit. Bildschirmzugriff bleibt bei Inaktivität und Wiederverbindung aktiv, bis er beendet, der Hauptschalter deaktiviert oder die Richtlinie geändert wird. Für ein dediziertes Gerät.</string>
<string name="bss_screen_access_off_desc">Bildschirmzugriff ist aus. Neuer begrenzter Zugriff verwendet standardmäßig %1$d Minuten Leerlauf.</string>
<string name="bss_screen_access_unlimited_desc">Mindestens eine Bildschirmfunktion bleibt bis zum ausdrücklichen Ausschalten aktiv.</string>
<string name="provider_usage_title">Nutzung &amp; Limits</string>
<string name="provider_usage_back">Zurück</string>
<string name="provider_usage_refresh">Nutzung aktualisieren</string>
<string name="provider_usage_intro">Kontolimits der Anbieter, die für diese Hermes-Verbindung konfiguriert sind.</string>
<string name="provider_usage_not_available">Diese Hermes-Verbindung stellt keine Anbieternutzung bereit. Aktualisieren Sie Hermes oder installieren/aktualisieren Sie das Relay-Plugin.</string>
<string name="provider_usage_none_configured">Für keinen sichtbaren Anbieter sind Kontonutzungsdaten verfügbar.</string>
<string name="provider_usage_loading">Anbieternutzung wird geladen…</string>
<string name="provider_usage_error">Anbieternutzung konnte nicht geladen werden.</string>
<string name="provider_usage_retry">Erneut versuchen</string>
<string name="provider_usage_percent">%1$d%% verwendet</string>
<string name="provider_usage_resets">Zurücksetzung in %1$s</string>
<string name="provider_usage_display_title">Anzeige in Einstellungen</string>
<string name="provider_usage_display_desc">Wählen Sie, wie die Kontonutzung in den Haupteinstellungen erscheint.</string>
<string name="provider_usage_mode_summary">Übersicht</string>
<string name="provider_usage_mode_expanded">Erweitert</string>
<string name="provider_usage_mode_hidden">Ausgeblendet</string>
<string name="provider_usage_providers_title">In den Haupteinstellungen anzeigen</string>
<string name="provider_usage_providers_desc">Wählen Sie, welche Anbieterkarten in den Haupteinstellungen erscheinen. Hier bleiben alle Anbieter sichtbar.</string>
<string name="provider_usage_settings_desc">Kontonutzung und Anbieterlimits</string>
<string name="provider_usage_settings_desc_relay">Durch Relay-Plugin erweiterte Nutzung und Limits</string>
<string name="provider_usage_settings_desc_basic">Hermes-Basisnutzung · Relay-Plugin ergänzt Pools und mehr</string>
<string name="provider_usage_customize">Anzeige</string>
<string name="provider_usage_hidden_hint">Nutzungskarten sind in den Einstellungen ausgeblendet.</string>
<string name="provider_usage_not_available_compact">Anbieternutzung ist nicht verfügbar. Aktualisieren Sie Hermes oder das Relay-Plugin.</string>
<string name="provider_usage_provider_not_configured">Auf diesem Host nicht konfiguriert</string>
<string name="provider_usage_provider_unavailable">Nutzung ist vorübergehend nicht verfügbar</string>
<string name="provider_usage_active_unknown">Für diese Sitzung gibt es noch keine aktiven Anmeldedaten.</string>
<string name="provider_usage_active_available">Aktiv · Verfügbar</string>
<string name="provider_usage_active_at_limit">Aktiv · Limit erreicht</string>
<string name="provider_usage_active">Aktiv</string>
<string name="provider_usage_available">Verfügbar</string>
<string name="provider_usage_at_limit">Limit erreicht</string>
<string name="provider_usage_unavailable_status">Nicht verfügbar</string>
<string name="provider_usage_renews_on">Verlängert sich am %1$s</string>
<string name="provider_usage_manage_credits">Guthaben verwalten</string>
<string name="provider_usage_capability_relay_title">Durch Relay-Plugin erweitert</string>
<string name="provider_usage_capability_relay_body">Anmeldedaten-Pools, strukturierte Nous-Guthaben und OpenCode Go werden vom Relay-Plugin bereitgestellt.</string>
<string name="provider_usage_capability_basic_title">Basisnutzung von Hermes</string>
<string name="provider_usage_capability_basic_body">Installieren oder aktualisieren Sie das Relay-Plugin für Anmeldedaten-Pools, strukturierte Nous-Guthaben und OpenCode Go.</string>
<string name="custom_theme_title">Benutzerdefiniert</string>
<string name="custom_theme_entry_summary">Eigene Themes erstellen und speichern</string>
<string name="custom_theme_your_presets">Deine Presets</string>
+39
View File
@@ -3981,6 +3981,45 @@
<string name="bridge_timed_unlimited_warning">Sin límite de inactividad. El acceso continúa tras inactividad y reconexión hasta finalizarlo, desactivar el interruptor maestro o cambiar la política. Ideal para un dispositivo dedicado.</string>
<string name="bss_screen_access_off_desc">El acceso a pantalla está desactivado. El acceso finito nuevo usa %1$d minutos de inactividad por defecto.</string>
<string name="bss_screen_access_unlimited_desc">Al menos una capacidad de pantalla permanece activa hasta desactivarla explícitamente.</string>
<string name="provider_usage_title">Uso y límites</string>
<string name="provider_usage_back">Atrás</string>
<string name="provider_usage_refresh">Actualizar uso</string>
<string name="provider_usage_intro">Límites de cuenta de los proveedores configurados en esta conexión de Hermes.</string>
<string name="provider_usage_not_available">Esta conexión de Hermes no expone el uso de proveedores. Actualiza Hermes o instala/actualiza el complemento Relay.</string>
<string name="provider_usage_none_configured">Ningún proveedor visible tiene datos de uso de cuenta disponibles.</string>
<string name="provider_usage_loading">Cargando uso de proveedores…</string>
<string name="provider_usage_error">No se pudo cargar el uso de proveedores.</string>
<string name="provider_usage_retry">Reintentar</string>
<string name="provider_usage_percent">%1$d%% usado</string>
<string name="provider_usage_resets">Se restablece en %1$s</string>
<string name="provider_usage_display_title">Visualización en Ajustes</string>
<string name="provider_usage_display_desc">Elige cómo aparece el uso de cuenta en la pantalla principal de Ajustes.</string>
<string name="provider_usage_mode_summary">Resumen</string>
<string name="provider_usage_mode_expanded">Ampliado</string>
<string name="provider_usage_mode_hidden">Oculto</string>
<string name="provider_usage_providers_title">Mostrar en Ajustes principales</string>
<string name="provider_usage_providers_desc">Elige qué tarjetas de proveedores aparecen en Ajustes principales. Aquí siempre se muestran todos.</string>
<string name="provider_usage_settings_desc">Uso de cuenta y límites de proveedores</string>
<string name="provider_usage_settings_desc_relay">Uso y límites ampliados por el complemento Relay</string>
<string name="provider_usage_settings_desc_basic">Uso básico de Hermes · Relay añade grupos y más</string>
<string name="provider_usage_customize">Visualización</string>
<string name="provider_usage_hidden_hint">Las tarjetas de uso están ocultas en Ajustes.</string>
<string name="provider_usage_not_available_compact">El uso de proveedores no está disponible. Actualiza Hermes o el complemento Relay.</string>
<string name="provider_usage_provider_not_configured">No configurado en este host</string>
<string name="provider_usage_provider_unavailable">El uso no está disponible temporalmente</string>
<string name="provider_usage_active_unknown">Esta sesión aún no tiene una credencial activa.</string>
<string name="provider_usage_active_available">Activa · Disponible</string>
<string name="provider_usage_active_at_limit">Activa · Límite alcanzado</string>
<string name="provider_usage_active">Activa</string>
<string name="provider_usage_available">Disponible</string>
<string name="provider_usage_at_limit">Límite alcanzado</string>
<string name="provider_usage_unavailable_status">No disponible</string>
<string name="provider_usage_renews_on">Se renueva el %1$s</string>
<string name="provider_usage_manage_credits">Gestionar créditos</string>
<string name="provider_usage_capability_relay_title">Ampliado por el complemento Relay</string>
<string name="provider_usage_capability_relay_body">Los grupos de credenciales, los saldos estructurados de Nous y OpenCode Go los proporciona el complemento Relay.</string>
<string name="provider_usage_capability_basic_title">Uso básico de Hermes</string>
<string name="provider_usage_capability_basic_body">Instala o actualiza el complemento Relay para obtener grupos de credenciales, saldos estructurados de Nous y OpenCode Go.</string>
<string name="custom_theme_title">Personalizado</string>
<string name="custom_theme_entry_summary">Crea y guarda tus propios temas</string>
<string name="custom_theme_your_presets">Tus preajustes</string>
+39
View File
@@ -4294,6 +4294,45 @@
<string name="bridge_timed_unlimited_warning">アイドルタイムアウトはありません。終了、マスター無効化、またはポリシー変更まで、非操作時や再接続後も画面アクセスが続きます。専用端末向けです。</string>
<string name="bss_screen_access_off_desc">画面アクセスはオフです。新しい有限アクセスの既定アイドル制限は %1$d 分です。</string>
<string name="bss_screen_access_unlimited_desc">少なくとも 1 つの画面機能が明示的にオフにするまで有効です。</string>
<string name="provider_usage_title">使用量と上限</string>
<string name="provider_usage_back">戻る</string>
<string name="provider_usage_refresh">使用量を更新</string>
<string name="provider_usage_intro">この Hermes 接続に設定されたプロバイダーのアカウント上限です。</string>
<string name="provider_usage_not_available">この Hermes 接続はプロバイダー使用量を公開していません。Hermes を更新するか、Relay プラグインをインストール/更新してください。</string>
<string name="provider_usage_none_configured">表示中のプロバイダーに利用可能なアカウント使用量がありません。</string>
<string name="provider_usage_loading">プロバイダー使用量を読み込み中…</string>
<string name="provider_usage_error">プロバイダー使用量を読み込めませんでした。</string>
<string name="provider_usage_retry">再試行</string>
<string name="provider_usage_percent">%1$d%% 使用済み</string>
<string name="provider_usage_resets">%1$s後にリセット</string>
<string name="provider_usage_display_title">設定での表示</string>
<string name="provider_usage_display_desc">メインの設定画面にアカウント使用量を表示する方法を選びます。</string>
<string name="provider_usage_mode_summary">概要</string>
<string name="provider_usage_mode_expanded">展開</string>
<string name="provider_usage_mode_hidden">非表示</string>
<string name="provider_usage_providers_title">メイン設定に表示</string>
<string name="provider_usage_providers_desc">メイン設定に表示するプロバイダーカードを選びます。ここではすべて表示されます。</string>
<string name="provider_usage_settings_desc">アカウント使用量とプロバイダー上限</string>
<string name="provider_usage_settings_desc_relay">Relay プラグインで拡張された使用量と上限</string>
<string name="provider_usage_settings_desc_basic">Hermes の基本使用量 · Relay でプールなどを追加</string>
<string name="provider_usage_customize">表示</string>
<string name="provider_usage_hidden_hint">設定では使用量カードが非表示です。</string>
<string name="provider_usage_not_available_compact">プロバイダー使用量を利用できません。Hermes または Relay プラグインを更新してください。</string>
<string name="provider_usage_provider_not_configured">このホストでは未設定です</string>
<string name="provider_usage_provider_unavailable">使用量は一時的に利用できません</string>
<string name="provider_usage_active_unknown">このセッションにはまだ使用中の認証情報がありません。</string>
<string name="provider_usage_active_available">使用中 · 利用可能</string>
<string name="provider_usage_active_at_limit">使用中 · 上限到達</string>
<string name="provider_usage_active">使用中</string>
<string name="provider_usage_available">利用可能</string>
<string name="provider_usage_at_limit">上限到達</string>
<string name="provider_usage_unavailable_status">利用不可</string>
<string name="provider_usage_renews_on">%1$s に更新</string>
<string name="provider_usage_manage_credits">クレジットを管理</string>
<string name="provider_usage_capability_relay_title">Relay プラグインで拡張</string>
<string name="provider_usage_capability_relay_body">認証情報プール、構造化された Nous 残高、OpenCode Go は Relay プラグインによって提供されます。</string>
<string name="provider_usage_capability_basic_title">Hermes の基本使用量</string>
<string name="provider_usage_capability_basic_body">認証情報プール、構造化された Nous 残高、OpenCode Go を利用するには Relay プラグインをインストールまたは更新してください。</string>
<string name="custom_theme_title">カスタム</string>
<string name="custom_theme_entry_summary">独自のテーマを作成して保存します</string>
<string name="custom_theme_your_presets">保存したテーマ</string>
+39
View File
@@ -4023,6 +4023,45 @@
<string name="bridge_timed_unlimited_warning">Без тайм-аута. Доступ сохраняется при бездействии и переподключении, пока не завершен, не выключен главный переключатель или не изменена политика. Для выделенного устройства.</string>
<string name="bss_screen_access_off_desc">Доступ к экрану выключен. Новый ограниченный доступ по умолчанию использует %1$d минут бездействия.</string>
<string name="bss_screen_access_unlimited_desc">Хотя бы одна экранная возможность активна до явного отключения.</string>
<string name="provider_usage_title">Использование и лимиты</string>
<string name="provider_usage_back">Назад</string>
<string name="provider_usage_refresh">Обновить использование</string>
<string name="provider_usage_intro">Лимиты учётных записей поставщиков, настроенных для этого подключения Hermes.</string>
<string name="provider_usage_not_available">Это подключение Hermes не предоставляет данные поставщиков. Обновите Hermes или установите/обновите плагин Relay.</string>
<string name="provider_usage_none_configured">Ни у одного видимого поставщика нет доступных данных об использовании.</string>
<string name="provider_usage_loading">Загрузка данных поставщиков…</string>
<string name="provider_usage_error">Не удалось загрузить данные поставщиков.</string>
<string name="provider_usage_retry">Повторить</string>
<string name="provider_usage_percent">Использовано %1$d%%</string>
<string name="provider_usage_resets">Сброс через %1$s</string>
<string name="provider_usage_display_title">Отображение в настройках</string>
<string name="provider_usage_display_desc">Выберите, как использование учётной записи отображается на главном экране настроек.</string>
<string name="provider_usage_mode_summary">Сводка</string>
<string name="provider_usage_mode_expanded">Развёрнуто</string>
<string name="provider_usage_mode_hidden">Скрыто</string>
<string name="provider_usage_providers_title">Показывать в основных настройках</string>
<string name="provider_usage_providers_desc">Выберите карточки поставщиков для главного экрана настроек. Здесь всегда видны все поставщики.</string>
<string name="provider_usage_settings_desc">Использование учётной записи и лимиты поставщиков</string>
<string name="provider_usage_settings_desc_relay">Расширенные данные и лимиты от плагина Relay</string>
<string name="provider_usage_settings_desc_basic">Базовые данные Hermes · Relay добавляет пулы и другое</string>
<string name="provider_usage_customize">Отображение</string>
<string name="provider_usage_hidden_hint">Карточки использования скрыты в настройках.</string>
<string name="provider_usage_not_available_compact">Данные поставщиков недоступны. Обновите Hermes или плагин Relay.</string>
<string name="provider_usage_provider_not_configured">Не настроено на этом хосте</string>
<string name="provider_usage_provider_unavailable">Данные временно недоступны</string>
<string name="provider_usage_active_unknown">Для этого сеанса ещё нет активных учётных данных.</string>
<string name="provider_usage_active_available">Активно · Доступно</string>
<string name="provider_usage_active_at_limit">Активно · Лимит исчерпан</string>
<string name="provider_usage_active">Активно</string>
<string name="provider_usage_available">Доступно</string>
<string name="provider_usage_at_limit">Лимит исчерпан</string>
<string name="provider_usage_unavailable_status">Недоступно</string>
<string name="provider_usage_renews_on">Продление: %1$s</string>
<string name="provider_usage_manage_credits">Управление кредитами</string>
<string name="provider_usage_capability_relay_title">Расширено плагином Relay</string>
<string name="provider_usage_capability_relay_body">Пулы учётных данных, структурированные балансы Nous и OpenCode Go предоставляются плагином Relay.</string>
<string name="provider_usage_capability_basic_title">Базовые данные Hermes</string>
<string name="provider_usage_capability_basic_body">Установите или обновите плагин Relay для пулов учётных данных, структурированных балансов Nous и OpenCode Go.</string>
<string name="custom_theme_title">Своя тема</string>
<string name="custom_theme_entry_summary">Создавайте и сохраняйте собственные темы</string>
<string name="custom_theme_your_presets">Ваши темы</string>
+39
View File
@@ -4339,4 +4339,43 @@
<string name="bridge_timed_allow">Allow access</string>
<string name="bridge_timed_end_now">End now</string>
<string name="bridge_timed_ended_snackbar">Screen access ended. Permanent grants are still available.</string>
<string name="provider_usage_title">Usage &amp; limits</string>
<string name="provider_usage_back">Back</string>
<string name="provider_usage_refresh">Refresh usage</string>
<string name="provider_usage_intro">Account limits from providers configured on this Hermes connection.</string>
<string name="provider_usage_not_available">This Hermes connection does not expose provider usage. Update Hermes or install/update the Relay plugin to enable it.</string>
<string name="provider_usage_none_configured">No visible provider has account usage available.</string>
<string name="provider_usage_loading">Loading provider usage…</string>
<string name="provider_usage_error">Couldn\'t load provider usage.</string>
<string name="provider_usage_retry">Retry</string>
<string name="provider_usage_percent">%1$d%% used</string>
<string name="provider_usage_resets">Resets in %1$s</string>
<string name="provider_usage_display_title">Settings display</string>
<string name="provider_usage_display_desc">Choose how account usage appears on the main Settings screen.</string>
<string name="provider_usage_mode_summary">Summary</string>
<string name="provider_usage_mode_expanded">Expanded</string>
<string name="provider_usage_mode_hidden">Hidden</string>
<string name="provider_usage_providers_title">Show on main Settings</string>
<string name="provider_usage_providers_desc">Choose which provider cards appear on the main Settings page. All providers remain visible here.</string>
<string name="provider_usage_settings_desc">Account usage and provider limits</string>
<string name="provider_usage_settings_desc_relay">Relay plugin enhanced usage and limits</string>
<string name="provider_usage_settings_desc_basic">Basic Hermes usage · Relay plugin adds pools and more</string>
<string name="provider_usage_customize">Display</string>
<string name="provider_usage_hidden_hint">Usage cards are hidden on Settings.</string>
<string name="provider_usage_not_available_compact">Provider usage is unavailable. Update Hermes or install/update the Relay plugin.</string>
<string name="provider_usage_provider_not_configured">Not configured on this host</string>
<string name="provider_usage_provider_unavailable">Usage is temporarily unavailable</string>
<string name="provider_usage_active_unknown">No active credential yet for this session.</string>
<string name="provider_usage_active_available">Active · Available</string>
<string name="provider_usage_active_at_limit">Active · At limit</string>
<string name="provider_usage_active">Active</string>
<string name="provider_usage_available">Available</string>
<string name="provider_usage_at_limit">At limit</string>
<string name="provider_usage_unavailable_status">Unavailable</string>
<string name="provider_usage_renews_on">Renews %1$s</string>
<string name="provider_usage_manage_credits">Manage credits</string>
<string name="provider_usage_capability_relay_title">Relay plugin enhanced</string>
<string name="provider_usage_capability_relay_body">Credential pools, structured Nous balances, and OpenCode Go are provided by the Relay plugin.</string>
<string name="provider_usage_capability_basic_title">Basic usage from Hermes</string>
<string name="provider_usage_capability_basic_body">Install or update the Relay plugin for credential pools, structured Nous balances, and OpenCode Go.</string>
</resources>
@@ -0,0 +1,69 @@
package com.hermesandroid.relay.data
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class ProviderUsagePreferencesTest {
@get:Rule
val tempFolder = TemporaryFolder()
private lateinit var file: File
private lateinit var scope: CoroutineScope
private lateinit var repository: ProviderUsagePreferencesRepository
@Before
fun setUp() {
file = tempFolder.newFile("provider_usage.preferences_pb").also { it.delete() }
scope = CoroutineScope(Dispatchers.IO + Job())
repository = ProviderUsagePreferencesRepository(
PreferenceDataStoreFactory.create(scope = scope, produceFile = { file }),
)
}
@After
fun tearDown() {
scope.cancel()
}
@Test
fun defaultsToSummaryWithSupportedProvidersVisible() = runTest {
val preferences = repository.preferences.first()
assertEquals(ProviderUsageLandingMode.Summary, preferences.landingMode)
assertEquals(
setOf("openai-codex", "nous", "opencode-go"),
preferences.visibleProviders,
)
}
@Test
fun persistsDisplayMode() = runTest {
repository.setLandingMode(ProviderUsageLandingMode.Expanded)
val preferences = repository.preferences.first()
assertEquals(ProviderUsageLandingMode.Expanded, preferences.landingMode)
}
@Test
fun persistsIndependentProviderVisibility() = runTest {
repository.setProviderVisible("nous", false)
val preferences = repository.preferences.first()
assertFalse("nous" in preferences.visibleProviders)
assertTrue("openai-codex" in preferences.visibleProviders)
assertTrue("opencode-go" in preferences.visibleProviders)
}
}
@@ -0,0 +1,99 @@
package com.hermesandroid.relay.network.relay
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class RelayHttpClientProviderUsageTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun parsesProviderNeutralPayloadAndAuthenticates() = runTest {
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""
{
"schema_version": 2,
"capabilities": ["credential_pools", "structured_balances", "opencode_go"],
"providers": [
{
"id": "openai-codex",
"display_name": "Codex",
"status": "available",
"plan": "Plus",
"active_credential_id": "abc123",
"active_credential_state": "known",
"credentials": [{
"id": "abc123",
"label": "Work",
"active": true,
"status": "available",
"windows": []
}],
"windows": [{
"id": "session",
"label": "Session",
"used_percent": 42.5,
"reset_at": "2026-08-22T00:00:00Z"
}]
}
]
}
""".trimIndent(),
),
)
val response = client(token = "paired-token")
.fetchProviderUsage(profile = "victor", sessionId = "session-42")
.getOrThrow()!!
val request = server.takeRequest()
assertEquals("/usage/providers?profile=victor&session_id=session-42", request.path)
assertEquals("Bearer paired-token", request.getHeader("Authorization"))
assertEquals("Codex", response.providers.single().displayName)
assertEquals(42.5, response.providers.single().windows.single().usedPercent!!, 0.001)
assertEquals("Work", response.providers.single().credentials.single().label)
assertTrue(response.providers.single().credentials.single().active)
assertTrue(response.relayEnhanced)
}
@Test
fun unsupportedHostIsNullSuccess() = runTest {
server.enqueue(MockResponse().setResponseCode(404))
val response = client(token = "paired-token").fetchProviderUsage()
assertTrue(response.isSuccess)
assertNull(response.getOrNull())
}
@Test
fun unpairedIsUnsupportedAndDoesNotHitServer() = runTest {
val response = client(token = null).fetchProviderUsage()
assertTrue(response.isSuccess)
assertNull(response.getOrNull())
assertEquals(0, server.requestCount)
}
private fun client(token: String?) = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { server.url("/").toString() },
sessionTokenProvider = { token },
)
}
@@ -126,6 +126,44 @@ class DashboardApiClientTest {
assertEquals(listOf("default", "worker"), status.gateways.single().servedProfiles)
}
@Test
fun getProviderUsage_carriesSessionAndParsesCredentialPool() = runTest {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""
{
"schema_version": 2,
"capabilities": ["credential_pools", "structured_balances", "opencode_go"],
"providers": [{
"id": "openai-codex",
"display_name": "Codex",
"status": "available",
"active_credential_state": "known",
"credentials": [{
"id": "abc123",
"label": "bailey",
"active": true,
"status": "available"
}]
}]
}
""".trimIndent(),
),
)
val usage = DashboardApiClient(baseUrl = server.url("/").toString())
.getProviderUsage(profile = "victor", sessionId = "session/42")
.getOrThrow()!!
val request = server.takeRequest().requestUrl!!
assertEquals("/api/plugins/hermes-relay/provider-usage", request.encodedPath)
assertEquals("victor", request.queryParameter("profile"))
assertEquals("session/42", request.queryParameter("session_id"))
assertEquals("bailey", usage.providers.single().credentials.single().label)
assertTrue(usage.providers.single().credentials.single().active)
assertTrue(usage.relayEnhanced)
}
@Test
fun getModelOptions_alwaysRequestsUnconfiguredProviders() = runTest {
// HRUI-022: newer upstream hides unconfigured provider skeleton rows
@@ -0,0 +1,26 @@
package com.hermesandroid.relay.network.usage
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ProviderUsageModelsTest {
@Test
fun completeRelayCapabilitySetIsEnhanced() {
assertTrue(
ProviderUsageResponse(
capabilities = ProviderUsageResponse.RELAY_ENHANCED_CAPABILITIES,
).relayEnhanced,
)
}
@Test
fun missingOrPartialCapabilitiesRemainBasic() {
assertFalse(ProviderUsageResponse().relayEnhanced)
assertFalse(
ProviderUsageResponse(
capabilities = setOf("credential_pools", "structured_balances"),
).relayEnhanced,
)
}
}
@@ -1005,10 +1005,13 @@ class ChatViewModelGatewayInboundTurnTest {
@Test
fun stopClearsStaleBusyStateAfterTerminalBubbleAlreadySettled() {
// Exercise Stop's route-independent fallback without the Gateway
// orphan-state observer settling this synthetic state first.
viewModel.streamingEndpoint = "sessions"
handler.onTextDelta("stale-answer", "Finished answer")
handler.onTurnComplete("stale-answer")
assertTrue(handler.isStreaming.value)
assertFalse(handler.messages.value.single().isStreaming)
assertTrue(handler.isStreaming.value)
viewModel.cancelStream()
@@ -1016,10 +1019,46 @@ class ChatViewModelGatewayInboundTurnTest {
assertNull(handler.turnStatus.value)
}
@Test
fun completedGatewayBubbleAutomaticallySettlesComposerWithoutInput() {
handler.onTextDelta("completed-answer", "Finished answer")
handler.onTurnComplete("completed-answer")
awaitCondition { !handler.isStreaming.value }
assertFalse(handler.messages.value.single().isStreaming)
assertFalse(gatewayClient.hasActiveTurnForSession(STORED_SESSION_ID))
assertTrue(gatewayHarness.rpcLog.none { it.first == "prompt.submit" })
assertTrue(gatewayHarness.rpcLog.none { it.first == "session.interrupt" })
}
@Test
fun completedBubbleKeepsComposerBusyWhileGatewaySessionStillOwnsTheRun() {
viewModel.sendMessage("Continue through another assistant turn")
gatewayHarness.awaitRpc("prompt.submit")
awaitCondition { gatewayClient.hasActiveTurnForSession(STORED_SESSION_ID) }
val assistantId = handler.messages.value.single { it.role == MessageRole.ASSISTANT }.id
handler.onTextDelta(assistantId, "First assistant message")
handler.onTurnComplete(assistantId)
shadowOf(Looper.getMainLooper()).idle()
assertTrue(handler.isStreaming.value)
assertTrue(gatewayClient.hasActiveTurnForSession(STORED_SESSION_ID))
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Final assistant message") },
"live-resumed",
),
)
awaitCondition { !handler.isStreaming.value }
}
@Test
fun newChatClearsStaleBusyStateWhenNoLiveGatewayTurnRemains() {
handler.onTextDelta("stale-answer", "Finished answer")
handler.onTurnComplete("stale-answer")
assertTrue(handler.isStreaming.value)
assertFalse(gatewayClient.hasActiveTurn())
+57 -1
View File
@@ -777,7 +777,13 @@ Four sub-decisions captured together:
contains no tokens, secrets, config contents, or filesystem paths;
`/bridge/activity` and `/media/inspect` remain loopback-only.
4. **Dashboard backend is a thin proxy; relay is source of truth.** `plugin_api.py` exposes five routes at `/api/plugins/hermes-relay/{overview,sessions,bridge-activity,media,push}` and forwards to the relay over `httpx.AsyncClient` with a 5-second timeout. No business logic — the plugin never maintains its own state, never caches, never retries. Relay connect-error / timeout / 5xx translate to `HTTPException(502, detail=…)` carrying the relay address, so the UI can render a "relay unreachable at 127.0.0.1:8767" banner; 4xx passes through verbatim. The one exception is `/push` — since FCM isn't wired, this route is a static stub returning `{configured: false, reason: "FCM not yet wired; …"}` with no network call. Keeps the four-tab nav layout correct for when FCM lands; swapping in real data only touches `PushConsole.jsx` + `plugin_api.py::get_push`.
4. **Dashboard backend is a thin proxy; relay is source of truth.** `plugin_api.py` exposes five routes at `/api/plugins/hermes-relay/{overview,sessions,bridge-activity,media,push}` and forwards to the relay over `httpx.AsyncClient` with a 5-second timeout. It does not cache or retry. Relay connect-error / timeout / 5xx translate to `HTTPException(502, detail=…)` carrying the relay address, so the UI can render a "relay unreachable at 127.0.0.1:8767" banner; 4xx passes through verbatim. The `/push` route is a static stub until FCM is wired.
**2026-08-21 amendment:** the authenticated `/provider-usage` route is a
deliberate process-local exception. The Dashboard process owns the live
Gateway session, so this Relay-plugin route resolves its active credential
on demand and feeds the same provider-neutral Relay adapter without waiting
for another turn. It stores no provider secret or additional Dashboard state.
**Consequences:**
@@ -3737,3 +3743,53 @@ the active connection, and a group cannot gain a competing mobile writer.
Route-scoped Relay media, voice, background delivery notifications, autonomous
peer delivery, and writable room control remain separate capabilities rather
than implicit authority gained from appearing in the union roster.
---
## ADR 67 — Provider account usage is Relay-enhanced, normalized, and user-presented at top level
**Context.** Android had no account-limit surface even though current Hermes
already models Codex and Nous usage. A proposed OpenCode Go-only Settings card
introduced a Relay proxy, fixed provider windows, and inferred dollar spend
from rounded percentages. That shape could not represent multiple providers,
made changing plans look authoritative, and placed a provider-specific card on
the Settings landing page for hosts where it did not apply.
**Decision.** Android owns one top-level **Usage & limits** Settings destination,
parallel to Hermes Management rather than nested inside it. The device-level
landing presentation is Summary by default, with opt-in Expanded and Hidden
modes plus per-provider visibility. The full destination remains reachable in
all three modes.
When Dashboard auth is available, the client first calls the Relay-owned
Dashboard-plugin usage route so the live session's active credential can be
resolved directly. Paired standalone clients fall back to Relay
`GET /usage/providers`, which requires the operator to set
`RELAY_PROVIDER_USAGE_ENABLED=1`; additive upstream Gateway `account.usage`
remains the bounded single-account fallback. Relay reuses Hermes's existing
account model for Codex and Nous and supplies the missing OpenCode Go adapter. OpenCode Go
renders only the percentage and reset values returned by the provider; Android
does not infer dollars or embed plan caps. Provider keys stay host-side.
The active profile is carried on the compatibility request and validated before
Hermes's task-local home override scopes every account lookup, so concurrent
profiles never collapse onto the root account.
For Codex pools, Android also carries its current Gateway session id. The
authenticated Relay Dashboard-plugin route runs in the process that owns the
live agent, reads its authoritative stable pool-entry id on demand, and returns
the provider-neutral usage response without waiting for another turn. Relay
fetches each pool entry's usage host-side, returns safe labels and hashed opaque
ids, and marks the exact active entry. Turn hooks retain a secret-free
profile-local snapshot only for standalone Relay clients without Dashboard
access. Missing live-agent/session evidence is rendered as active unknown; it
is never inferred from the first credential or from the legacy singleton.
xAI and other providers remain absent until their account-level source and
credential scope can be represented honestly. Per-request or session spend is
not labeled as an account quota.
**Consequences.** Relay can evolve richer provider adapters without requiring
an upstream Hermes change, while vanilla/current Hermes retains a bounded
single-account fallback. Merely configuring a provider credential does not
expose tokens to paired devices. The Android UI can add providers without
adding provider-specific screens or silently treating missing data as zero usage.
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "d8f28da0ced315350223c9181fd7b93ba61a10037e2862e708017adf1b6f4f98",
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "d8f28da0ced315350223c9181fd7b93ba61a10037e2862e708017adf1b6f4f98",
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "d8f28da0ced315350223c9181fd7b93ba61a10037e2862e708017adf1b6f4f98",
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "d8f28da0ced315350223c9181fd7b93ba61a10037e2862e708017adf1b6f4f98",
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "d8f28da0ced315350223c9181fd7b93ba61a10037e2862e708017adf1b6f4f98",
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "d8f28da0ced315350223c9181fd7b93ba61a10037e2862e708017adf1b6f4f98",
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+2
View File
@@ -681,6 +681,8 @@ MEDIA:hermes-relay://<url-safe-16-byte-token>
**Server:** media routes on `plugin/relay/server.py`:
- `POST /media/register` — **loopback-only**. Body `{"path", "content_type", "file_name"}`. Validates path is absolute, resolves (`os.path.realpath`) under an allowed root, exists, is a regular file, fits under `RELAY_MEDIA_MAX_SIZE_MB`. Generates `secrets.token_urlsafe(16)` (128 bits entropy), stores the token → entry mapping in an in-memory `OrderedDict` LRU (capped at `RELAY_MEDIA_LRU_CAP`, TTL `RELAY_MEDIA_TTL_SECONDS`). Returns `{ok, token, expires_at}`. Used when a host-local tool explicitly wants to publish a file.
- `GET /api/plugins/hermes-relay/provider-usage?profile=<id>&session_id=<id>` — authenticated Dashboard-plugin surface that resolves the active Codex pool entry directly from the live Gateway session, without requiring another turn. Android prefers this route when Dashboard auth is available.
- `GET /usage/providers?profile=<id>&session_id=<id>` — bearer-authenticated standalone Relay surface for Android provider account limits. Disabled unless `RELAY_PROVIDER_USAGE_ENABLED=1`. The validated profile ID scopes every credential/account lookup through Hermes's context-local home override. It reuses Hermes account snapshots for Codex and Nous, adds OpenCode Go percentage/reset windows, and returns no provider secrets. For Codex it reports every bounded pool entry with a safe label, hashed opaque id, effective status, and usage windows; the optional Gateway session id correlates the active entry from a secret-free profile-local hook snapshot. If that exact evidence is absent, active state is explicitly unknown. Android falls back to this route, then additive upstream Gateway `account.usage` as a single-account fallback.
- `GET /media/{token}` — requires `Authorization: Bearer <session_token>` against the existing `SessionManager` (same token WSS uses). Streams the file via `web.FileResponse` with the registered content type plus `Content-Disposition: inline; filename="..."` if the entry has a file name. 401 on missing/invalid bearer, 404 on unknown/expired token.
- `GET /media/by-path?path=<abs>&content_type=<optional>` — requires bearer auth. Shares the same sandbox validation as `/media/register` via a common `validate_media_path()` helper: absolute path, `realpath`-resolves under an allowed root, exists, is a regular file, fits under the size cap. Content-Type is the phone's hint if provided, otherwise guessed via `mimetypes.guess_type()`. This route exists specifically for **LLM-emitted bare-path markers** — upstream `agent/prompt_builder.py` instructs the model to include `MEDIA:/absolute/path/to/file` in its response text, so the bare-path form is the agent's native output, not just a fallback. 401 auth, 403 sandbox, 404 missing file.
- `POST /media/upload` — bearer-auth'd small upload route for phone-originated media. Accepts base64 content, writes a temp file, and registers it into the same media registry.
+43 -3
View File
@@ -2,9 +2,10 @@
Loopback-only; mounted by hermes-agent at ``/api/plugins/hermes-relay/*``.
Each route is a thin pass-through to the already-running relay HTTP server
on ``127.0.0.1:{HERMES_RELAY_PORT}``. No business logic lives here — the
relay stays the source of truth.
Most routes are thin pass-throughs to the already-running relay HTTP server
on ``127.0.0.1:{HERMES_RELAY_PORT}``. Provider usage is the deliberate
exception: the Dashboard process owns the live Gateway session and can resolve
its active credential without waiting for another turn.
Route map
---------
@@ -13,6 +14,7 @@ Route map
- ``GET /bridge-activity`` → relay ``GET /bridge/activity`` (forwards ``limit``)
- ``GET /media`` → relay ``GET /media/inspect`` (forwards ``include_expired``)
- ``GET /agent-context`` → relay ``GET /context/injected`` + local env settings
- ``GET /provider-usage`` → live-session-aware provider usage from this plugin
- ``GET /push`` → static stub (no network call) until FCM is wired
Error translation
@@ -236,6 +238,44 @@ async def get_agent_context() -> dict[str, Any]:
}
@router.get("/provider-usage")
async def get_provider_usage(
profile: Optional[str] = Query(default=None),
session_id: Optional[str] = Query(default=None),
) -> dict[str, Any]:
"""Return provider usage with the live session's active pool entry.
This route runs inside the Dashboard process that owns ``tui_gateway``.
Unlike the standalone Relay server, it can read the already-instantiated
agent directly and therefore does not need a new turn to learn which
credential is active.
"""
provider_usage = _plugin_module("relay.provider_usage")
hooks = _plugin_module("hooks")
try:
profile_home = provider_usage.resolve_profile_home(
str(_hermes_home() / "config.yaml"),
profile,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
active_credential_id = None
live = hooks.resolve_live_active_credential(session_id or "")
if (
isinstance(live, dict)
and live.get("provider_id") == "openai-codex"
and FsPath(live.get("profile_home")).resolve() == profile_home
):
active_credential_id = str(live.get("credential_id") or "") or None
return await provider_usage.collect_provider_usage(
profile_home=profile_home,
session_id=session_id,
active_credential_id=active_credential_id,
)
@router.get("/phone/config")
async def get_phone_config() -> dict[str, Any]:
"""Phone-platform home-channel config for the Management tab.
+38 -1
View File
@@ -7,8 +7,10 @@ Uses FastAPI's ``TestClient`` + ``httpx.MockTransport`` patched over
from __future__ import annotations
import unittest
from pathlib import Path
from types import SimpleNamespace
from typing import Callable, Optional
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import FastAPI
@@ -96,6 +98,41 @@ class SessionsTests(PluginApiTestCase):
self.assertEqual(resp.json(), payload)
class ProviderUsageTests(PluginApiTestCase):
def test_reads_active_credential_from_live_dashboard_session(self) -> None:
profile_home = Path("/profiles/victor").resolve()
provider_usage = SimpleNamespace(
resolve_profile_home=lambda _config, _profile: profile_home,
collect_provider_usage=AsyncMock(
return_value={"schema_version": 2, "providers": []}
),
)
hooks = SimpleNamespace(
resolve_live_active_credential=lambda _session: {
"profile_home": profile_home,
"provider_id": "openai-codex",
"credential_id": "entry-2",
}
)
with patch.object(
plugin_api,
"_plugin_module",
side_effect=lambda name: hooks if name == "hooks" else provider_usage,
):
response = self.client.get(
"/provider-usage",
params={"profile": "victor", "session_id": "session-2"},
)
self.assertEqual(response.status_code, 200)
provider_usage.collect_provider_usage.assert_awaited_once_with(
profile_home=profile_home,
session_id="session-2",
active_credential_id="entry-2",
)
class BridgeActivityTests(PluginApiTestCase):
def test_limit_param_is_forwarded(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
+100 -4
View File
@@ -1,8 +1,9 @@
"""Lifecycle hooks for the hermes-relay plugin.
Registered via ``ctx.register_hook(...)``. There is exactly one hook here:
``on_session_start``, which performs a single fast, fully-guarded loopback
probe of the relay's ``/health`` endpoint and caches the result.
Registered via ``ctx.register_hook(...)``. Session-start performs a single
fast, fully-guarded loopback health probe. Turn-boundary hooks also persist the
stable credential-pool entry selected by the live Gateway session so Relay can
report the correct account without ever receiving its token.
IMPORTANT — runs in the gateway process
---------------------------------------
@@ -30,6 +31,7 @@ import logging
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
@@ -113,6 +115,94 @@ def on_session_start(**kwargs: Any) -> None:
return None
def resolve_live_active_credential(session_id: str) -> dict[str, Any] | None:
"""Resolve a live Gateway session's active credential without exposing it."""
session_id = str(session_id or "").strip()
if not session_id:
return None
try:
from tui_gateway import server as gateway_server
sessions = getattr(gateway_server, "_sessions", {})
def resolve_record():
direct = sessions.get(session_id)
if isinstance(direct, dict):
return session_id, direct
for gateway_id, candidate in sessions.items():
if not isinstance(candidate, dict):
continue
agent = candidate.get("agent")
aliases = {
str(candidate.get("session_key") or ""),
str(getattr(agent, "session_id", "") or ""),
}
if session_id in aliases:
return str(gateway_id), candidate
return None, None
lock = getattr(gateway_server, "_sessions_lock", None)
if lock is None:
gateway_id, record = resolve_record()
else:
with lock:
gateway_id, record = resolve_record()
if not isinstance(record, dict):
return None
agent = record.get("agent")
credential_id = str(getattr(agent, "_credential_pool_entry_id", "") or "").strip()
pool = getattr(agent, "_credential_pool", None)
provider_id = str(getattr(pool, "provider", "") or "").strip()
if not credential_id or not provider_id:
return None
raw_home = record.get("profile_home")
if raw_home:
profile_home = Path(raw_home).expanduser().resolve()
else:
from hermes_constants import get_hermes_home
profile_home = Path(get_hermes_home()).expanduser().resolve()
aliases = {
value
for value in (
session_id,
str(gateway_id or ""),
str(record.get("session_key") or ""),
str(getattr(agent, "session_id", "") or ""),
)
if value
}
return {
"profile_home": profile_home,
"provider_id": provider_id,
"credential_id": credential_id,
"session_ids": aliases,
}
except Exception as exc: # noqa: BLE001 -- live lookup must fail open
logger.debug("active credential lookup skipped: %s", exc)
return None
def capture_active_credential(**kwargs: Any) -> None:
"""Persist the current live session's stable credential id, if available."""
try:
resolved = resolve_live_active_credential(str(kwargs.get("session_id") or ""))
if resolved is None:
return None
from .relay.active_credentials import record_active_credential_aliases
record_active_credential_aliases(
resolved["profile_home"],
session_ids=resolved["session_ids"],
provider_id=resolved["provider_id"],
credential_id=resolved["credential_id"],
)
except Exception as exc: # noqa: BLE001 -- this hook must always fail open
logger.debug("active credential capture skipped: %s", exc)
return None
def register_hooks(ctx) -> None:
"""Register the ``on_session_start`` hook with the plugin host.
@@ -121,5 +211,11 @@ def register_hooks(ctx) -> None:
"""
try:
ctx.register_hook("on_session_start", on_session_start)
except (AttributeError, TypeError) as exc:
except (AttributeError, TypeError, ValueError) as exc:
logger.debug("register_hook unavailable; skipping on_session_start: %s", exc)
return
for hook_name in ("pre_llm_call", "post_llm_call"):
try:
ctx.register_hook(hook_name, capture_active_credential)
except (AttributeError, TypeError, ValueError) as exc:
logger.debug("register_hook unavailable; skipping %s: %s", hook_name, exc)
+144
View File
@@ -0,0 +1,144 @@
"""Secret-free active credential snapshots shared by Gateway hooks and Relay.
The Gateway and Relay server commonly run in separate processes. Hooks record
only the stable pool-entry id selected by a live session; provider tokens never
leave the owning Gateway process.
"""
from __future__ import annotations
import json
import os
import tempfile
import threading
import time
from pathlib import Path
from collections.abc import Iterable
from typing import Any
_STATE_FILE = "hermes-relay-active-credentials.json"
_MAX_SESSIONS = 64
_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
_LOCK = threading.Lock()
def state_path(profile_home: Path) -> Path:
return profile_home / _STATE_FILE
def _read(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, ValueError, TypeError):
return {"schema_version": 1, "sessions": {}}
sessions = payload.get("sessions") if isinstance(payload, dict) else None
return {
"schema_version": 1,
"sessions": sessions if isinstance(sessions, dict) else {},
}
def record_active_credential(
profile_home: Path,
*,
session_id: str,
provider_id: str,
credential_id: str,
) -> None:
"""Atomically record a bounded, secret-free active credential mapping."""
record_active_credential_aliases(
profile_home,
session_ids=(session_id,),
provider_id=provider_id,
credential_id=credential_id,
)
def record_active_credential_aliases(
profile_home: Path,
*,
session_ids: Iterable[str],
provider_id: str,
credential_id: str,
) -> None:
"""Atomically map every authoritative Gateway/session alias to one entry."""
aliases = {
str(session_id or "").strip()[:160]
for session_id in session_ids
if str(session_id or "").strip()
}
provider = str(provider_id or "").strip()[:80]
credential = str(credential_id or "").strip()[:160]
if not aliases or not provider or not credential:
return
path = state_path(profile_home)
now = time.time()
with _LOCK:
payload = _read(path)
sessions = payload["sessions"]
for session in aliases:
sessions[session] = {
"provider_id": provider,
"credential_id": credential,
"observed_at": now,
}
retained = sorted(
(
(key, row)
for key, row in sessions.items()
if isinstance(row, dict)
and isinstance(row.get("observed_at"), (int, float))
and now - float(row["observed_at"]) <= _MAX_AGE_SECONDS
),
key=lambda item: float(item[1]["observed_at"]),
reverse=True,
)[:_MAX_SESSIONS]
payload["sessions"] = dict(retained)
path.parent.mkdir(parents=True, exist_ok=True)
fd, raw_tmp = tempfile.mkstemp(prefix=f".{_STATE_FILE}.", dir=str(path.parent))
tmp = Path(raw_tmp)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, separators=(",", ":"), sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
finally:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
def read_active_credential(
profile_home: Path,
*,
session_id: str | None,
provider_id: str,
) -> dict[str, Any] | None:
"""Return the exact session mapping, or ``None`` when it is not proven."""
session = str(session_id or "").strip()
if not session:
return None
row = _read(state_path(profile_home))["sessions"].get(session)
if not isinstance(row, dict) or row.get("provider_id") != provider_id:
return None
observed_at = row.get("observed_at")
if not isinstance(observed_at, (int, float)):
return None
if time.time() - float(observed_at) > _MAX_AGE_SECONDS:
return None
credential_id = str(row.get("credential_id") or "").strip()
if not credential_id:
return None
return {"credential_id": credential_id, "observed_at": float(observed_at)}
+11
View File
@@ -102,6 +102,11 @@ class RelayConfig:
trust_proxy_headers: bool = False
allow_insecure_api_bearer: bool = False
# Provider-account usage can expose billing and quota metadata to paired
# devices. Provider credentials alone are not consent to that disclosure;
# operators must explicitly enable the read-only mobile surface.
provider_usage_enabled: bool = False
# Provider-neutral voice output broker. This is the default assistant
# speech renderer: final Hermes text goes in, streamed provider PCM comes
# out. Realtime providers remain available separately as agent-mode tests.
@@ -289,6 +294,12 @@ class RelayConfig:
if insecure_api_bearer in ("1", "true", "yes", "on"):
config.allow_insecure_api_bearer = True
provider_usage = os.getenv(
"RELAY_PROVIDER_USAGE_ENABLED", ""
).strip().lower()
if provider_usage in ("1", "true", "yes", "on"):
config.provider_usage_enabled = True
apply_voice_output_config_file(config)
apply_realtime_voice_config_file(config)
+531
View File
@@ -0,0 +1,531 @@
"""Provider-neutral account usage snapshots for paired mobile clients.
Hermes already owns provider credentials and the canonical account-usage model.
Relay reuses that model, adds credential-pool and balance structure for Android,
and supplies the missing OpenCode Go adapter. Provider keys remain host-side
and are never serialized into the response.
"""
from __future__ import annotations
import asyncio
import hashlib
import math
import re
from urllib.parse import urlparse
from pathlib import Path
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable
import aiohttp
SCHEMA_VERSION = 2
RELAY_CAPABILITIES = (
"credential_pools",
"structured_balances",
"opencode_go",
)
_OPENCODE_GO_DEFAULT_BASE_URL = "https://opencode.ai/zen/go/v1"
_OPENCODE_GO_USER_AGENT = "curl/8.4.0"
_MAX_DETAIL_LENGTH = 240
_PROFILE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
def resolve_profile_home(config_path: str, requested_profile: str | None) -> Path:
"""Resolve an exact Hermes profile home without mutating process globals."""
root = Path(config_path).expanduser().resolve().parent
profile = str(requested_profile or "").strip().lower()
if profile in {"", "default"}:
try:
active = (root / "active_profile").read_text(encoding="utf-8").strip().lower()
except (OSError, UnicodeError):
active = ""
if _PROFILE_ID.fullmatch(active):
candidate = (root / "profiles" / active).resolve()
if candidate.parent == (root / "profiles").resolve() and (candidate / "config.yaml").is_file():
return candidate
return root
if not _PROFILE_ID.fullmatch(profile):
raise ValueError("invalid profile")
candidate = (root / "profiles" / profile).resolve()
if candidate.parent != (root / "profiles").resolve() or not (candidate / "config.yaml").is_file():
raise ValueError("unknown profile")
return candidate
def _set_home(profile_home: Path | None):
if profile_home is None:
return None
from hermes_constants import set_hermes_home_override
return set_hermes_home_override(profile_home)
def _reset_home(token) -> None:
if token is None:
return
from hermes_constants import reset_hermes_home_override
reset_hermes_home_override(token)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _bounded_text(value: Any, limit: int = _MAX_DETAIL_LENGTH) -> str | None:
text = str(value or "").strip()
return text[:limit] if text else None
def _iso(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, datetime):
dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
return _bounded_text(value, 80)
def _iso_epoch(value: Any) -> str | None:
if isinstance(value, (int, float)) and not isinstance(value, bool):
try:
return _iso(datetime.fromtimestamp(float(value), timezone.utc))
except (OverflowError, OSError, ValueError):
return None
return _iso(value)
def unavailable_provider(
provider_id: str,
display_name: str,
*,
status: str = "not_configured",
message: str | None = None,
) -> dict[str, Any]:
return {
"id": provider_id,
"display_name": display_name,
"status": status,
"source": None,
"fetched_at": None,
"plan": None,
"windows": [],
"details": [],
"balances": [],
"renews_at": None,
"action_url": None,
"credentials": [],
"active_credential_id": None,
"active_credential_state": "unknown",
"message": _bounded_text(message),
}
def serialize_account_snapshot(
snapshot: Any,
*,
provider_id: str,
display_name: str,
) -> dict[str, Any]:
"""Serialize upstream ``AccountUsageSnapshot`` without provider secrets."""
if snapshot is None or not bool(getattr(snapshot, "available", False)):
return unavailable_provider(provider_id, display_name)
windows: list[dict[str, Any]] = []
for index, window in enumerate(tuple(getattr(snapshot, "windows", ()) or ())[:8]):
raw_percent = getattr(window, "used_percent", None)
percent: float | None = None
if isinstance(raw_percent, (int, float)) and not isinstance(raw_percent, bool):
if math.isfinite(float(raw_percent)):
percent = max(0.0, min(100.0, float(raw_percent)))
label = _bounded_text(getattr(window, "label", None), 60) or f"Window {index + 1}"
windows.append(
{
"id": label.lower().replace(" ", "_")[:40],
"label": label,
"used_percent": percent,
"reset_at": _iso(getattr(window, "reset_at", None)),
"detail": _bounded_text(getattr(window, "detail", None)),
}
)
details = [
text
for item in tuple(getattr(snapshot, "details", ()) or ())[:8]
if (text := _bounded_text(item)) is not None
]
return {
"id": provider_id,
"display_name": display_name,
"status": "available",
"source": _bounded_text(getattr(snapshot, "source", None), 60),
"fetched_at": _iso(getattr(snapshot, "fetched_at", None)) or _now_iso(),
"plan": _bounded_text(getattr(snapshot, "plan", None), 80),
"windows": windows,
"details": details,
"balances": [],
"renews_at": None,
"action_url": None,
"credentials": [],
"active_credential_id": None,
"active_credential_state": "unknown",
"message": None,
}
def _public_credential_id(credential_id: str) -> str:
return hashlib.sha256(credential_id.encode("utf-8")).hexdigest()[:12]
def _effective_credential_status(entry: Any, snapshot: Any) -> str:
pool_status = str(getattr(entry, "last_status", "") or "").lower()
if pool_status in {"dead", "invalid"}:
return "unavailable"
if pool_status in {"exhausted", "rate_limited", "cooldown"}:
return "at_limit"
windows = tuple(getattr(snapshot, "windows", ()) or ()) if snapshot is not None else ()
if any(float(getattr(window, "used_percent", 0) or 0) >= 100 for window in windows):
return "at_limit"
return "available" if bool(getattr(snapshot, "available", False)) else "unavailable"
async def fetch_codex_usage(
profile_home: Path | None = None,
*,
session_id: str | None = None,
active_credential_id: str | None = None,
snapshot_fetcher: Callable[..., Any] | None = None,
pool_loader: Callable[[str], Any] | None = None,
) -> dict[str, Any]:
token = _set_home(profile_home)
try:
if pool_loader is None:
from agent.credential_pool import load_pool
pool_loader = load_pool
entries = pool_loader("openai-codex").entries()[:8]
except Exception:
return unavailable_provider(
"openai-codex",
"Codex",
status="unavailable",
message="Could not load Codex usage",
)
finally:
_reset_home(token)
if not entries:
return unavailable_provider("openai-codex", "Codex")
if snapshot_fetcher is None:
from agent.account_usage import _fetch_codex_account_usage
snapshot_fetcher = _fetch_codex_account_usage
def fetch_entry_snapshot(entry: Any) -> Any:
entry_token = _set_home(profile_home)
try:
return snapshot_fetcher(
base_url=getattr(entry, "runtime_base_url", None),
api_key=entry.runtime_api_key,
)
finally:
_reset_home(entry_token)
async def fetch_entry(entry: Any) -> tuple[Any, Any]:
try:
snapshot = await asyncio.to_thread(fetch_entry_snapshot, entry)
return entry, snapshot
except Exception:
return entry, None
fetched = await asyncio.gather(*(fetch_entry(entry) for entry in entries))
active_mapping = None
if active_credential_id:
active_raw_id = str(active_credential_id).strip()
elif profile_home is not None:
from .active_credentials import read_active_credential
active_mapping = read_active_credential(
profile_home,
session_id=session_id,
provider_id="openai-codex",
)
active_raw_id = active_mapping["credential_id"] if active_mapping else None
else:
active_raw_id = None
if active_raw_id not in {str(getattr(entry, "id", "")) for entry, _ in fetched}:
active_raw_id = None
active_state = "known" if active_raw_id else "unknown"
if len(fetched) == 1 and active_raw_id is None:
active_raw_id = str(getattr(fetched[0][0], "id", ""))
active_state = "single_credential"
credentials: list[dict[str, Any]] = []
active_provider: dict[str, Any] | None = None
for index, (entry, snapshot) in enumerate(fetched):
raw_id = str(getattr(entry, "id", ""))
public_id = _public_credential_id(raw_id)
serialized = serialize_account_snapshot(
snapshot,
provider_id="openai-codex",
display_name="Codex",
)
status = _effective_credential_status(entry, snapshot)
credential = {
"id": public_id,
"label": _bounded_text(getattr(entry, "label", None), 80) or f"Credential {index + 1}",
"active": raw_id == active_raw_id,
"status": status,
"pool_status": _bounded_text(getattr(entry, "last_status", None), 40),
"last_status_at": _iso_epoch(getattr(entry, "last_status_at", None)),
"reset_at": _iso_epoch(getattr(entry, "last_error_reset_at", None)),
"plan": serialized["plan"],
"windows": serialized["windows"],
"details": serialized["details"],
"message": serialized["message"],
}
credentials.append(credential)
if credential["active"]:
active_provider = serialized
available_count = sum(row["status"] == "available" for row in credentials)
limited_count = sum(row["status"] == "at_limit" for row in credentials)
summary = active_provider or {
"source": "credential_pool",
"fetched_at": _now_iso(),
"plan": None,
"windows": [],
"details": [],
}
return {
"id": "openai-codex",
"display_name": "Codex",
"status": "available",
"source": summary.get("source") or "credential_pool",
"fetched_at": summary.get("fetched_at") or _now_iso(),
"plan": summary.get("plan"),
"windows": summary.get("windows", []),
"details": [
f"{available_count} available · {limited_count} at limit · {len(credentials)} total"
],
"credentials": credentials,
"active_credential_id": (
_public_credential_id(active_raw_id) if active_raw_id else None
),
"active_credential_state": active_state,
"active_observed_at": (
_iso(datetime.fromtimestamp(active_mapping["observed_at"], timezone.utc))
if active_mapping and active_state == "known"
else None
),
"message": None,
}
async def fetch_nous_usage(
profile_home: Path | None = None,
*,
account_fetcher: Callable[..., Any] | None = None,
) -> dict[str, Any]:
token = _set_home(profile_home)
try:
from agent.account_usage import build_nous_credits_snapshot
from hermes_cli.nous_account import get_nous_portal_account_info, nous_portal_topup_url
if account_fetcher is None:
account_fetcher = get_nous_portal_account_info
account = await asyncio.to_thread(account_fetcher, force_fresh=True)
snapshot = build_nous_credits_snapshot(account)
result = serialize_account_snapshot(
snapshot,
provider_id="nous",
display_name="Nous",
)
if not result["status"] == "available":
return result
def balance(balance_id: str, label: str, value: Any) -> dict[str, Any] | None:
if not isinstance(value, (int, float)) or isinstance(value, bool):
return None
amount = float(value)
if not math.isfinite(amount):
return None
return {"id": balance_id, "label": label, "amount": amount, "currency": "USD"}
access = getattr(account, "paid_service_access_info", None)
subscription = getattr(account, "subscription", None)
result["balances"] = [
item
for item in (
balance("total", "Total usable", getattr(access, "total_usable_credits", None)),
balance(
"subscription",
"Subscription",
getattr(access, "subscription_credits_remaining", None),
),
balance(
"top_up",
"Top-up",
getattr(access, "purchased_credits_remaining", None),
),
balance("rollover", "Rollover", getattr(subscription, "rollover_credits", None)),
)
if item is not None
]
result["renews_at"] = _iso(getattr(subscription, "current_period_end", None))
action_url = _bounded_text(nous_portal_topup_url(account), 500)
parsed_action = urlparse(action_url or "")
result["action_url"] = (
action_url if parsed_action.scheme in {"http", "https"} and parsed_action.netloc else None
)
# Structured fields own mobile presentation. Preserve only genuinely
# additional status lines; never render raw URLs or ISO timestamps.
result["details"] = [
detail for detail in result["details"] if detail.startswith("Status:")
]
return result
except Exception:
return unavailable_provider(
"nous",
"Nous",
status="unavailable",
message="Could not load Nous usage",
)
finally:
_reset_home(token)
async def fetch_opencode_go_usage(
*,
profile_home: Path | None = None,
session_factory: Callable[[], Any] = aiohttp.ClientSession,
credential_resolver: Callable[[str], dict[str, Any]] | None = None,
) -> dict[str, Any]:
token = _set_home(profile_home)
try:
if credential_resolver is None:
from hermes_cli.auth import resolve_api_key_provider_credentials
credential_resolver = resolve_api_key_provider_credentials
credentials = await asyncio.to_thread(
credential_resolver,
"opencode-go",
)
except Exception:
credentials = {}
finally:
_reset_home(token)
api_key = str(credentials.get("api_key") or "").strip()
if not api_key:
return unavailable_provider("opencode-go", "OpenCode Go")
base_url = str(credentials.get("base_url") or _OPENCODE_GO_DEFAULT_BASE_URL).rstrip("/")
try:
async with session_factory() as session:
async with session.get(
f"{base_url}/usage",
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": _OPENCODE_GO_USER_AGENT,
},
timeout=aiohttp.ClientTimeout(total=15),
) as response:
if response.status != 200:
return unavailable_provider(
"opencode-go",
"OpenCode Go",
status="unavailable",
message=f"Provider returned HTTP {response.status}",
)
payload = await response.json()
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError, TypeError):
return unavailable_provider(
"opencode-go",
"OpenCode Go",
status="unavailable",
message="Could not load OpenCode Go usage",
)
usage = payload.get("usage") if isinstance(payload, dict) else None
if not isinstance(usage, dict):
return unavailable_provider(
"opencode-go",
"OpenCode Go",
status="unavailable",
message="Provider returned an unsupported usage payload",
)
windows: list[dict[str, Any]] = []
for key, label in (
("rolling", "Session · 5h"),
("weekly", "Weekly"),
("monthly", "Monthly"),
):
raw = usage.get(key)
if not isinstance(raw, dict):
continue
raw_percent = raw.get("percent")
if not isinstance(raw_percent, (int, float)) or isinstance(raw_percent, bool):
continue
percent = float(raw_percent)
if not math.isfinite(percent):
continue
windows.append(
{
"id": key,
"label": label,
"used_percent": max(0.0, min(100.0, percent)),
"reset_at": _iso(raw.get("resetsAt")),
"detail": None,
}
)
if not windows:
return unavailable_provider(
"opencode-go",
"OpenCode Go",
status="unavailable",
message="Provider returned no usage windows",
)
return {
"id": "opencode-go",
"display_name": "OpenCode Go",
"status": "available",
"source": "provider_api",
"fetched_at": _now_iso(),
"plan": None,
"windows": windows,
"details": [],
"message": None,
}
async def collect_provider_usage(
*,
profile_home: Path | None = None,
session_id: str | None = None,
active_credential_id: str | None = None,
codex_fetcher: Callable[..., Awaitable[dict[str, Any]]] = fetch_codex_usage,
nous_fetcher: Callable[[Path | None], Awaitable[dict[str, Any]]] = fetch_nous_usage,
opencode_fetcher: Callable[..., Awaitable[dict[str, Any]]] = fetch_opencode_go_usage,
) -> dict[str, Any]:
providers = await asyncio.gather(
codex_fetcher(
profile_home,
session_id=session_id,
active_credential_id=active_credential_id,
),
nous_fetcher(profile_home),
opencode_fetcher(profile_home=profile_home),
)
return {
"schema_version": SCHEMA_VERSION,
"fetched_at": _now_iso(),
"capabilities": list(RELAY_CAPABILITIES),
"providers": providers,
}
+23
View File
@@ -93,6 +93,7 @@ from .model_capabilities import (
ModelCapabilityResolver,
SCHEMA_VERSION as MODEL_CAPABILITIES_SCHEMA_VERSION,
)
from .provider_usage import collect_provider_usage, resolve_profile_home
from .session_store import read_phone_threads
from .voice import VoiceHandler
from .voice_output import VoiceOutputHandler
@@ -1172,6 +1173,27 @@ async def handle_sessions_extend(request: web.Request) -> web.Response:
)
async def handle_provider_usage(request: web.Request) -> web.Response:
"""Return provider-neutral account usage to an authenticated paired device."""
_require_bearer_session(request)
server: RelayServer = request.app["server"]
if not server.config.provider_usage_enabled:
raise web.HTTPNotFound(text="provider usage is not enabled on this host")
try:
profile_home = resolve_profile_home(
server.config.hermes_config_path,
request.query.get("profile"),
)
except ValueError as exc:
raise web.HTTPBadRequest(text=str(exc)) from exc
return web.json_response(
await collect_provider_usage(
profile_home=profile_home,
session_id=request.query.get("session_id"),
)
)
# ── Desktop tool dispatch (loopback HTTP shim for desktop_tool.py) ──────────
@@ -4682,6 +4704,7 @@ def create_app(config: RelayConfig) -> web.Application:
app.router.add_get("/sessions", handle_sessions_list)
app.router.add_delete("/sessions/{token_prefix}", handle_sessions_revoke)
app.router.add_patch("/sessions/{token_prefix}", handle_sessions_extend)
app.router.add_get("/usage/providers", handle_provider_usage)
app.router.add_get("/chat/image-activity", handle_image_activity)
# Desktop tool dispatch — HTTP shim called by `plugin/tools/desktop_tool.py`
# running inside hermes-gateway. Both endpoints loopback-only.
+63
View File
@@ -0,0 +1,63 @@
"""Tests for Relay's fail-open Gateway lifecycle hooks."""
from __future__ import annotations
import sys
import threading
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest import mock
from plugin.hooks import capture_active_credential, register_hooks
class RelayHookTests(unittest.TestCase):
def test_capture_records_only_stable_identity_for_exact_session(self) -> None:
agent = SimpleNamespace(
_credential_pool_entry_id="entry-2",
_credential_pool=SimpleNamespace(provider="openai-codex"),
)
gateway_server = SimpleNamespace(
_sessions={
"gateway-ui-2": {
"agent": agent,
"session_key": "session-2",
"profile_home": "/profiles/victor",
}
},
_sessions_lock=threading.Lock(),
)
tui_gateway = ModuleType("tui_gateway")
tui_gateway.server = gateway_server
with (
mock.patch.dict(sys.modules, {"tui_gateway": tui_gateway}),
mock.patch(
"plugin.relay.active_credentials.record_active_credential_aliases"
) as record,
):
capture_active_credential(session_id="session-2")
record.assert_called_once_with(
Path("/profiles/victor").resolve(),
session_ids={"session-2", "gateway-ui-2"},
provider_id="openai-codex",
credential_id="entry-2",
)
self.assertNotIn("api_key", record.call_args.kwargs)
def test_registration_keeps_older_hosts_working(self) -> None:
registered: list[str] = []
def register(name, _callback):
if name != "on_session_start":
raise ValueError("unknown hook")
registered.append(name)
register_hooks(SimpleNamespace(register_hook=register))
self.assertEqual(registered, ["on_session_start"])
if __name__ == "__main__":
unittest.main()
+290
View File
@@ -0,0 +1,290 @@
"""Tests for provider-neutral account usage and the paired-device endpoint."""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
import tempfile
import unittest
from unittest import mock
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase
from plugin.relay.config import RelayConfig
from plugin.relay.provider_usage import (
collect_provider_usage,
fetch_codex_usage,
fetch_nous_usage,
fetch_opencode_go_usage,
resolve_profile_home,
serialize_account_snapshot,
unavailable_provider,
)
from plugin.relay.active_credentials import record_active_credential
from plugin.relay.server import create_app
class _FakeResponse:
def __init__(self, status: int = 200, payload: dict | None = None):
self.status = status
self._payload = payload or {}
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def json(self):
return self._payload
class _FakeSession:
def __init__(self, response: _FakeResponse):
self.response = response
self.headers: dict | None = None
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
def get(self, _url, *, headers=None, timeout=None):
self.headers = headers
return self.response
class ProviderUsageModelTests(unittest.IsolatedAsyncioTestCase):
def test_profile_home_is_exact_and_rejects_traversal(self) -> None:
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
(root / "config.yaml").write_text("model: {}\n", encoding="utf-8")
victor = root / "profiles" / "victor"
victor.mkdir(parents=True)
(victor / "config.yaml").write_text("model: {}\n", encoding="utf-8")
self.assertEqual(resolve_profile_home(str(root / "config.yaml"), "Victor"), victor)
with self.assertRaises(ValueError):
resolve_profile_home(str(root / "config.yaml"), "../victor")
def test_serializes_upstream_snapshot_without_credentials(self) -> None:
snapshot = SimpleNamespace(
available=True,
source="usage_api",
fetched_at=datetime(2026, 8, 21, tzinfo=timezone.utc),
plan="Plus",
windows=(
SimpleNamespace(
label="Session",
used_percent=42.5,
reset_at=datetime(2026, 8, 22, tzinfo=timezone.utc),
detail=None,
),
),
details=("Credits balance: $4.20",),
)
result = serialize_account_snapshot(
snapshot,
provider_id="openai-codex",
display_name="Codex",
)
self.assertEqual(result["status"], "available")
self.assertEqual(result["windows"][0]["used_percent"], 42.5)
self.assertEqual(result["plan"], "Plus")
self.assertNotIn("token", result)
async def test_opencode_missing_key_is_not_configured(self) -> None:
result = await fetch_opencode_go_usage(credential_resolver=lambda _provider: {})
self.assertEqual(result["id"], "opencode-go")
self.assertEqual(result["status"], "not_configured")
async def test_nous_exposes_structured_balances_without_raw_mobile_details(self) -> None:
account = SimpleNamespace(
logged_in=True,
paid_service_access=True,
paid_service_access_info=SimpleNamespace(
subscription_credits_remaining=31.98,
purchased_credits_remaining=0.0,
total_usable_credits=31.98,
),
subscription=SimpleNamespace(
plan="Plus",
monthly_credits=None,
credits_remaining=31.98,
rollover_credits=10.0,
current_period_end="2026-09-18T00:11:42.000Z",
),
portal_base_url="https://portal.nousresearch.com",
org_slug="example",
)
result = await fetch_nous_usage(
account_fetcher=lambda **_kwargs: account,
)
self.assertEqual(result["status"], "available")
self.assertEqual(result["plan"], "Plus")
self.assertEqual(result["balances"][0], {
"id": "total",
"label": "Total usable",
"amount": 31.98,
"currency": "USD",
})
self.assertEqual(result["renews_at"], "2026-09-18T00:11:42.000Z")
self.assertTrue(result["action_url"].endswith("/orgs/example/billing?topup=open"))
self.assertEqual(result["details"], [])
async def test_opencode_normalizes_windows_without_inventing_dollars(self) -> None:
fake = _FakeSession(
_FakeResponse(
payload={
"usage": {
"rolling": {"percent": 42, "resetsAt": "2026-08-22T00:00:00Z"},
"weekly": {"percent": 18},
}
}
)
)
result = await fetch_opencode_go_usage(
session_factory=lambda: fake,
credential_resolver=lambda _provider: {
"api_key": "secret",
"base_url": "https://opencode.ai/zen/go/v1",
},
)
self.assertEqual(result["status"], "available")
self.assertEqual([row["id"] for row in result["windows"]], ["rolling", "weekly"])
self.assertNotIn("limits", result)
self.assertEqual(fake.headers["Authorization"], "Bearer secret")
async def test_collection_keeps_provider_order_and_schema(self) -> None:
async def codex(_home, **_kwargs):
return unavailable_provider("openai-codex", "Codex")
async def nous(_home):
return unavailable_provider("nous", "Nous")
async def opencode(*, profile_home=None):
return unavailable_provider("opencode-go", "OpenCode Go")
result = await collect_provider_usage(
codex_fetcher=codex,
nous_fetcher=nous,
opencode_fetcher=opencode,
)
self.assertEqual(result["schema_version"], 2)
self.assertEqual(
result["capabilities"],
["credential_pools", "structured_balances", "opencode_go"],
)
self.assertEqual(
[row["id"] for row in result["providers"]],
["openai-codex", "nous", "opencode-go"],
)
async def test_codex_pool_marks_exact_live_session_credential_active(self) -> None:
with tempfile.TemporaryDirectory() as raw:
home = Path(raw)
record_active_credential(
home,
session_id="session-2",
provider_id="openai-codex",
credential_id="entry-2",
)
entries = [
SimpleNamespace(
id=f"entry-{index}",
label=f"Account {index}",
last_status="ok",
last_status_at=None,
last_error_reset_at=None,
runtime_base_url="https://chatgpt.com/backend-api/codex",
runtime_api_key=f"secret-{index}",
)
for index in (1, 2)
]
snapshots = {
"secret-1": SimpleNamespace(
available=True,
source="usage_api",
fetched_at=datetime(2026, 8, 21, tzinfo=timezone.utc),
plan="Pro",
windows=(SimpleNamespace(label="Session", used_percent=100, reset_at=None, detail=None),),
details=(),
),
"secret-2": SimpleNamespace(
available=True,
source="usage_api",
fetched_at=datetime(2026, 8, 21, tzinfo=timezone.utc),
plan="Pro",
windows=(SimpleNamespace(label="Session", used_percent=24, reset_at=None, detail=None),),
details=(),
),
}
result = await fetch_codex_usage(
home,
session_id="session-2",
pool_loader=lambda _provider: SimpleNamespace(entries=lambda: entries),
snapshot_fetcher=lambda *, api_key, base_url: snapshots[api_key],
)
self.assertEqual(result["active_credential_state"], "known")
active = next(row for row in result["credentials"] if row["active"])
self.assertEqual(active["label"], "Account 2")
self.assertEqual(active["windows"][0]["used_percent"], 24.0)
limited = next(row for row in result["credentials"] if row["label"] == "Account 1")
self.assertEqual(limited["status"], "at_limit")
self.assertNotIn("secret-2", str(result))
class ProviderUsageEndpointTests(AioHTTPTestCase):
async def get_application(self) -> web.Application:
return create_app(RelayConfig(provider_usage_enabled=self.usage_enabled))
@property
def usage_enabled(self) -> bool:
return True
def _server(self):
return self.app["server"]
async def _mint(self) -> str:
return self._server().sessions.create_session("phone", "device").token
async def test_requires_bearer(self) -> None:
response = await self.client.get("/usage/providers")
self.assertEqual(response.status, 401)
@mock.patch(
"plugin.relay.server.collect_provider_usage",
new=mock.AsyncMock(return_value={"schema_version": 1, "providers": []}),
)
async def test_returns_normalized_payload(self) -> None:
token = await self._mint()
response = await self.client.get(
"/usage/providers",
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(response.status, 200)
self.assertEqual((await response.json())["schema_version"], 1)
class ProviderUsageDisabledEndpointTests(ProviderUsageEndpointTests):
@property
def usage_enabled(self) -> bool:
return False
async def test_returns_normalized_payload(self) -> None:
token = await self._mint()
response = await self.client.get(
"/usage/providers",
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(response.status, 404)
if __name__ == "__main__":
unittest.main()
+7
View File
@@ -190,11 +190,18 @@ python -m plugin.relay --no-ssl
| `RELAY_PAIRING_CODE` | — | Pre-register a pairing code at startup (same effect as `--pairing-code`) |
| `RELAY_TRUST_PROXY_HEADERS` | `0` | Trust `X-Forwarded-Proto: https` from your own reverse proxy for Hermes API-key auth on `/voice/*` |
| `RELAY_ALLOW_INSECURE_API_BEARER` | `0` | Dev-only startup escape hatch for API-key voice auth over non-loopback plain HTTP. For a running relay, use `hermes relay insecure-api-key on` and `off` instead. |
| `RELAY_PROVIDER_USAGE_ENABLED` | `0` | Explicitly allow paired devices to read provider account usage and quota metadata through the Relay compatibility endpoint. Provider keys alone never enable this disclosure. |
| `RELAY_MEDIA_MAX_SIZE_MB` | `100` | Per-file size cap on `POST /media/register` (MediaRegistry, used for inbound media delivery — see ADR 14) |
| `RELAY_MEDIA_TTL_SECONDS` | `86400` | How long a registered media entry stays valid before the registry evicts it |
| `RELAY_MEDIA_LRU_CAP` | `500` | Max entries in the media registry before oldest-eviction kicks in |
| `RELAY_MEDIA_ALLOWED_ROOTS` | — | Additional absolute directory roots allowed on `/media/register` (colon-separated on Unix, `os.pathsep` on other platforms). Extends the auto-derived defaults (`tempfile.gettempdir()` + `HERMES_WORKSPACE` or `~/.hermes/workspace/`). |
The authenticated Relay Dashboard plugin can also return provider usage to a
signed-in Dashboard client and resolve the active Codex pool credential from
the live session. `RELAY_PROVIDER_USAGE_ENABLED` controls only the standalone
paired-device `/usage/providers` fallback; neither surface returns provider
credentials.
**Pairing alphabet:** As of 2026-04-11, the relay accepts any 6-character code from `A-Z / 0-9` (36 chars). The earlier "no ambiguous 0/O/1/I" 32-char restriction was dropped once the pairing flow became QR + HTTP — the phone-side generator in `AuthManager.kt` uses the full alphabet, and the restriction silently rejected roughly one in eight valid codes.
For Docker, systemd, and TLS setup, see [docs/relay-server.md](https://github.com/Codename-11/hermes-relay/blob/main/docs/relay-server.md).
+3
View File
@@ -177,6 +177,7 @@ All settings via environment variables:
| `RELAY_SECURE_LINK_BROKER_HOST_ID_FILE` | `~/.hermes/relay-secure-link/host-id` | Persistent opaque Reach host-ID file. |
| `RELAY_TRUST_PROXY_HEADERS` | `0` | Trust `X-Forwarded-Proto: https` from your own reverse proxy for Hermes API bearer auth on `/voice/*`. |
| `RELAY_ALLOW_INSECURE_API_BEARER` | `0` | Dev-only escape hatch for non-loopback plaintext Hermes API bearer auth on `/voice/*`. Leave off for production. For a running relay, use `hermes relay insecure-api-key on` or the standalone `hermes-relay insecure-api-key on` shim instead of restarting with env. |
| `RELAY_PROVIDER_USAGE_ENABLED` | `0` | Opt in to the bearer-authenticated `/usage/providers` compatibility endpoint. This exposes account quota/balance metadata to paired devices but never provider credentials. |
| `RELAY_MEDIA_MAX_SIZE_MB` | `100` | Per-file size cap for `POST /media/register` (inbound media pipeline — see ADR 14) |
| `RELAY_MEDIA_TTL_SECONDS` | `86400` | How long a registered media entry stays fetchable |
| `RELAY_MEDIA_LRU_CAP` | `500` | Max entries in the in-memory media registry before LRU eviction |
@@ -243,6 +244,8 @@ hermes relay compat [status|install|remove]
| `/sessions` | GET | Bearer-auth'd (same token the WSS channel uses). Returns all active paired devices with metadata — device name, token prefix (first 8 chars, full token never exposed), created/last-seen timestamps, session expiry, per-channel grants, transport hint, and `is_current` for the device matching the bearer. `math.inf` expiries serialize as `null` (never expire). |
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd. Revoke a paired device by token-prefix (≥ 4 chars). 200 on exact match, 404 on zero, 409 on ambiguous matches. Self-revoke is allowed and flagged via `revoked_self: true`. |
| `/sessions/{token_prefix}` | PATCH | Bearer-auth'd, self-targeted, and reduction-only. Body `{ttl_seconds?, grants?}` may shorten the caller's current lifetime or existing grants. Extending policy, adding grants, switching to never-expire, or changing another session requires a fresh operator-approved pairing flow. |
| `/api/plugins/hermes-relay/provider-usage` | GET | Dashboard-authenticated provider usage for Codex, Nous, and OpenCode Go. Optional `profile=<id>` and `session_id=<id>` scope the lookup and let the Dashboard plugin identify the active Codex pool credential directly from the live session. |
| `/usage/providers` | GET | Bearer-authenticated standalone Relay fallback for the same provider-neutral data. Optional `profile=<id>` and `session_id=<id>` scope credentials and active-session correlation. Disabled by default; set `RELAY_PROVIDER_USAGE_ENABLED=1`. Never returns provider credentials. |
| `/clipboard/inbox` | POST | Bearer-auth'd clipboard rendezvous used by remote clients before native platform clipboard fallback. |
| `/media/register` | POST | **Loopback only.** Register a host-local file with the `MediaRegistry` and receive an opaque token. Body: `{"path": "/abs/path", "content_type": "image/jpeg", "file_name": "screenshot.jpg"}`. Used by tools like `android_screenshot` so the agent can emit `MEDIA:hermes-relay://<token>` in chat and have the phone fetch bytes out-of-band. Path is sandboxed to `tempfile.gettempdir()` + `HERMES_WORKSPACE` + any `RELAY_MEDIA_ALLOWED_ROOTS`; symlink escape is rejected via `realpath`. Returns 400 on validation failure. See ADR 14. |
| `/media/upload` | POST | Bearer-auth'd small upload endpoint for phone-originated media. Accepts JSON `{file_name, content_type, content}` where `content` is base64 and registers the decoded bytes with the media registry. |