* fix(chat): meter flashes stale window on mid-chat model swap (issue #817) session.info updates the model label as soon as the backend applies a swap, but the context meter's denominator kept the old model's window until the next fetch — the chip briefly rendered the new label over the old window (e.g. 1M flashing before settling on 262k). - session.info with a DIFFERENT model now blanks the denominator (chip hides while resolving) and immediately re-fires fetchContextUsage() - fetchContextUsage writes the denominator ONCE, atomically: RPC context_max wins, REST model/info is the fallback — a stale pre-swap response can never override a fresh one - regression tests: swap re-fires the fetch and lands the new window; an identical session.info leaves the meter untouched * fix(chat): don't fall back to profile REST window on model swap (issue #817) After a session-scoped swap the live agent's context_breakdown can return empty (context_max 0) while the runtime warms up. The refetch then fell back to GET /api/model/info — PROFILE-scoped — which reports the profile's main-model window (e.g. 1M), resurrecting the stale window under the new label. The swap-triggered refetch now trusts only the RPC's live context_max; the chip stays hidden until the real window lands. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
3e9672f0ad
commit
9e741913fc
2 files changed
+144
-20
No files matched your search
@@ -691,22 +691,42 @@ class ChatViewModel(
|
||||
val model = info["model"] as? String
|
||||
val provider = info["provider"] as? String
|
||||
val reasoningEffort = info["reasoning_effort"] as? String
|
||||
val newModelLabel =
|
||||
if (model != null && provider != null) {
|
||||
"$provider/$model"
|
||||
} else {
|
||||
model
|
||||
}
|
||||
// Issue #817: on a REAL model swap the meter's denominator
|
||||
// still belongs to the old model until the next fetch.
|
||||
// Blank it so the chip hides instead of flashing a stale
|
||||
// window under the new label, and re-fire the fetch so the
|
||||
// new window lands immediately (no 30s poll wait). Only a
|
||||
// change from a known label counts — the first SessionInfo
|
||||
// of a session just sets the label.
|
||||
val previousLabel = _uiState.value.currentSessionModel
|
||||
val modelSwapped =
|
||||
previousLabel != null && newModelLabel != null && newModelLabel != previousLabel
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
currentSessionModel =
|
||||
if (model != null && provider != null) {
|
||||
"$provider/$model"
|
||||
} else {
|
||||
model ?: state.currentSessionModel
|
||||
},
|
||||
currentSessionModel = newModelLabel ?: state.currentSessionModel,
|
||||
reasoningLevel =
|
||||
if (reasoningEffort.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
reasoningEffort
|
||||
},
|
||||
fullContextTokens = if (modelSwapped) null else state.fullContextTokens,
|
||||
)
|
||||
}
|
||||
if (modelSwapped) {
|
||||
// Issue #817: after a swap the REST model/info window is
|
||||
// PROFILE-scoped and may describe the old model (e.g. a
|
||||
// session-scoped swap) — the meter must not fall back to
|
||||
// it. Wait for the RPC's live context_max instead; the
|
||||
// chip stays hidden until the real window lands.
|
||||
viewModelScope.launch { fetchContextUsage(skipRestFallback = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2295,23 +2315,25 @@ class ChatViewModel(
|
||||
* the other. Polled from [syncCurrentSession] via the 30s loop and re-fired
|
||||
* on model switch (the denominator changes).
|
||||
*/
|
||||
fun fetchContextUsage() {
|
||||
fun fetchContextUsage(skipRestFallback: Boolean = false) {
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
val profile = AuthManager.activeProfileId.value
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
// Denominator fallback: full context window (cheap, public, rarely
|
||||
// changes). The RPC's context_max below overrides it when present.
|
||||
// Kept as a local (not a state write) so both sources resolve
|
||||
// before ONE atomic update below (issue #817 — two independent
|
||||
// writes let a stale pre-swap value override a fresh one mid-swap).
|
||||
val fullResult =
|
||||
safeApiCall { ApiClient.hermesApi.getModelInfo() }
|
||||
if (fullResult is NetworkResult.Success) {
|
||||
val full =
|
||||
val restFull =
|
||||
if (fullResult is NetworkResult.Success) {
|
||||
fullResult.data.effective_context_length
|
||||
?: fullResult.data.auto_context_length
|
||||
?: fullResult.data.config_context_length
|
||||
if (full != null && full > 0L) {
|
||||
_uiState.update { it.copy(fullContextTokens = full) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
// Numerator: live context occupancy from the gateway's live agent,
|
||||
// via the same RPC the desktop meter uses. `context_used` is the
|
||||
// real current prompt size (drops after compression); `context_max`
|
||||
@@ -2325,6 +2347,8 @@ class ChatViewModel(
|
||||
// resume result lands, so skip the RPCs until resume confirms the
|
||||
// runtime id. The REST parts below stay live (they key on the
|
||||
// storage id).
|
||||
var rpcUsed: Long? = null
|
||||
var rpcMax: Long? = null
|
||||
val rpcSessionId = runtimeSessionId
|
||||
if (rpcSessionId != null) {
|
||||
try {
|
||||
@@ -2335,14 +2359,8 @@ class ChatViewModel(
|
||||
)
|
||||
val ctx = parseContextBreakdown(result)
|
||||
if (ctx != null) {
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
usedContextTokens =
|
||||
ctx.contextUsed?.takeIf { it > 0L } ?: current.usedContextTokens,
|
||||
fullContextTokens =
|
||||
ctx.contextMax?.takeIf { it > 0L } ?: current.fullContextTokens,
|
||||
)
|
||||
}
|
||||
rpcUsed = ctx.contextUsed?.takeIf { it > 0L }
|
||||
rpcMax = ctx.contextMax?.takeIf { it > 0L }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Best-effort: RPC error/timeout/disconnect — keep last values.
|
||||
@@ -2364,6 +2382,18 @@ class ChatViewModel(
|
||||
// Best-effort: keep the last known badge value.
|
||||
}
|
||||
}
|
||||
// Issue #817: single atomic denominator write. The RPC's live
|
||||
// context_max wins, REST model/info is the fallback, and a stale
|
||||
// value from a pre-swap fetch can never overwrite a fresh one —
|
||||
// the meter always shows ONE coherent window.
|
||||
_uiState.update { current ->
|
||||
val fallbackFull =
|
||||
if (skipRestFallback) current.fullContextTokens else restFull ?: current.fullContextTokens
|
||||
current.copy(
|
||||
usedContextTokens = rpcUsed ?: current.usedContextTokens,
|
||||
fullContextTokens = rpcMax ?: fallbackFull,
|
||||
)
|
||||
}
|
||||
// Detail-sheet accounting (cumulative REST counters, informational).
|
||||
val usedResult =
|
||||
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
||||
|
||||
Reference in new issue
Block a user