Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60c5d9eeb | ||
|
|
9e201e54d7 | ||
|
|
8bb503eb6d | ||
|
|
c223dc690d | ||
|
|
44e3bb75cd | ||
|
|
4834fcbdf5 | ||
|
|
da7ea8ffe0 | ||
|
|
5c5c55d982 | ||
|
|
0208098687 | ||
|
|
34fc4c4693 |
+2
-2
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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)
|
||||
}
|
||||
}
|
||||
+99
@@ -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 },
|
||||
)
|
||||
}
|
||||
+38
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+41
-2
@@ -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
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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).
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user