refactor(#1375): type config.set, config.get, model.options and client.capabilities (#1393)

This commit is contained in:
m57 authored and GitHub committed 2026-09-30 00:40:24 +03:00
1 parent cb0463aefc
commit 1456e642e3
12 files changed
+339 -207

No files matched your search

@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.await
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.contract.ClientCapabilitiesParams
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethod
import com.m57.hermescontrol.data.ws.contract.RpcMethods
@@ -364,8 +365,11 @@ object HermesWsClient {
// Older gateways may reject this method; that is harmless.
runCatching {
send(
WsMethods.CLIENT_CAPABILITIES,
mapOf("server_requests" to true),
RpcMethods.CLIENT_CAPABILITIES.name,
encodeParams(
RpcMethods.CLIENT_CAPABILITIES,
ClientCapabilitiesParams(serverRequests = true),
),
onSent = { id -> capabilityRequestIds.add(id) },
queueIfDisconnected = false,
)
@@ -5,6 +5,8 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.contract.ModelOptionsParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
@@ -16,13 +18,8 @@ class ModelOptionsRepository(
private val connected: () -> Boolean = {
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
},
private val request: suspend (Map<String, Any>) -> Any? = { params ->
val deferred = HermesWsClient.request(WsMethods.MODEL_OPTIONS, params)
try {
deferred.await()
} finally {
if (!deferred.isCompleted) deferred.cancel()
}
private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params)
},
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
@@ -32,9 +29,7 @@ class ModelOptionsRepository(
currentCoroutineContext().ensureActive()
if (connected()) {
try {
val result = request(mapOf("refresh" to refresh, "include_unconfigured" to false))
// request() returns raw JsonElement, unlike the pushed-event Map representation.
val json = result as? JsonElement ?: error("Invalid model.options result")
val json = request(ModelOptionsParams(refresh = refresh, includeUnconfigured = false))
return NetworkResult.Success(OkHttpProvider.json.decodeFromJsonElement<ModelOptionsResponse>(json))
} catch (e: CancellationException) {
// A disconnected socket may cancel the independent RPC deferred. Only fall back
@@ -0,0 +1,41 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* `config.set` params. [key] and [value] are always encoded (no defaults); the rest are omitted
* when null. `profile` is injected by WsProfileParams, so it is not a field here.
*/
@Serializable
data class ConfigSetParams(
@SerialName("key") val key: String,
@SerialName("value") val value: String,
@SerialName("session_id") val sessionId: String? = null,
@SerialName("scope") val scope: String? = null,
@SerialName("confirm_expensive_model") val confirmExpensiveModel: Boolean? = null,
)
@Serializable
data class ConfigGetParams(
@SerialName("key") val key: String,
@SerialName("session_id") val sessionId: String? = null,
@SerialName("cwd") val cwd: String? = null,
)
/**
* `model.options` params. Flags are `Boolean?` so an explicit `false` is still sent while an
* unset flag is omitted (the client Json has `encodeDefaults = false`).
*/
@Serializable
data class ModelOptionsParams(
@SerialName("session_id") val sessionId: String? = null,
@SerialName("explicit_only") val explicitOnly: Boolean? = null,
@SerialName("include_unconfigured") val includeUnconfigured: Boolean? = null,
@SerialName("refresh") val refresh: Boolean? = null,
)
@Serializable
data class ClientCapabilitiesParams(
@SerialName("server_requests") val serverRequests: Boolean,
)
@@ -395,6 +395,38 @@ object RpcMethods {
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val CONFIG_SET: RpcMethod<ConfigSetParams, JsonElement> =
RpcMethod(
WsMethods.CONFIG_SET,
ConfigSetParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val CONFIG_GET: RpcMethod<ConfigGetParams, JsonElement> =
RpcMethod(
WsMethods.CONFIG_GET,
ConfigGetParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val MODEL_OPTIONS: RpcMethod<ModelOptionsParams, JsonElement> =
RpcMethod(
WsMethods.MODEL_OPTIONS,
ModelOptionsParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val CLIENT_CAPABILITIES: RpcMethod<ClientCapabilitiesParams, JsonElement> =
RpcMethod(
WsMethods.CLIENT_CAPABILITIES,
ClientCapabilitiesParams.serializer(),
JsonElement.serializer(),
)
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
val all: List<RpcMethod<*, *>> =
listOf(
@@ -446,5 +478,9 @@ object RpcMethods {
PROJECTS_LIST,
PROFILES_LIST,
PROFILES_CONFIGURE,
CONFIG_SET,
CONFIG_GET,
MODEL_OPTIONS,
CLIENT_CAPABILITIES,
)
}
@@ -12,7 +12,8 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ModelCatalogStore
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.toAny
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
@@ -51,7 +52,7 @@ class ChatModelSwitchDelegate(
private val ioDispatcher: CoroutineDispatcher,
private val uiState: MutableStateFlow<ChatUiState>,
private val runtimeSessionId: () -> String?,
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
private val wsSend: (params: ConfigSetParams, onSent: ((String) -> Unit)?) -> Unit,
private val trackRequest: (id: String, method: String) -> Unit,
private val addAssistantMessage: (text: String) -> Unit,
private val handleSlashCommand: (command: String) -> Unit,
@@ -64,7 +65,7 @@ class ChatModelSwitchDelegate(
{ refresh -> ModelCatalogStore.shared.ensureLoaded(forceRefresh = refresh) },
private val getPinnedModels: () -> List<PinnedModel> = { AuthManager.getPinnedModels() },
private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
private val wsRequest: (suspend (method: String, params: Map<String, Any>) -> Any?)? = null,
private val wsRequest: (suspend (params: ConfigSetParams) -> JsonElement)? = null,
) {
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
private val pendingFastSwitchRequests = ConcurrentHashMap<String, ActiveFastSwitch>()
@@ -300,20 +301,15 @@ class ChatModelSwitchDelegate(
unconfirmedTargetModel = spec
val switchSeq = ++modelSwitchSequence
val params =
mutableMapOf<String, Any>(
"key" to "model",
"value" to spec,
"session_id" to sessionId,
ConfigSetParams(
key = "model",
value = spec,
sessionId = sessionId,
confirmExpensiveModel = true.takeIf { confirmExpensive },
)
if (confirmExpensive) {
params["confirm_expensive_model"] = true
}
scope.launch(ioDispatcher) {
wsSend(
WsMethods.CONFIG_SET,
params,
) { id ->
trackRequest(id, WsMethods.CONFIG_SET)
wsSend(params) { id ->
trackRequest(id, RpcMethods.CONFIG_SET.name)
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel, switchSeq)
}
}
@@ -408,14 +404,13 @@ class ChatModelSwitchDelegate(
uiState.update { it.copy(isFastModeChanging = true) }
scope.launch(ioDispatcher) {
wsSend(
WsMethods.CONFIG_SET,
mapOf(
"key" to "fast",
"value" to if (target) "fast" else "normal",
"session_id" to sessionId,
ConfigSetParams(
key = "fast",
value = if (target) "fast" else "normal",
sessionId = sessionId,
),
) { id ->
trackRequest(id, WsMethods.CONFIG_SET)
trackRequest(id, RpcMethods.CONFIG_SET.name)
pendingFastSwitchRequests[id] = ActiveFastSwitch(target)
}
}
@@ -539,18 +534,18 @@ class ChatModelSwitchDelegate(
scope.launch(ioDispatcher) {
try {
val params =
mapOf(
"key" to "reasoning",
"value" to level,
"session_id" to sessionId,
"scope" to scopeName,
ConfigSetParams(
key = "reasoning",
value = level,
sessionId = sessionId,
scope = scopeName,
)
val result =
if (wsRequest != null) {
wsRequest.invoke(WsMethods.CONFIG_SET, params)
wsRequest.invoke(params)
} else {
wsSend(WsMethods.CONFIG_SET, params) { reqId ->
trackRequest(reqId, WsMethods.CONFIG_SET)
wsSend(params) { reqId ->
trackRequest(reqId, RpcMethods.CONFIG_SET.name)
}
null
}
@@ -558,14 +553,7 @@ class ChatModelSwitchDelegate(
if (opSeq == reasoningOpSequence && capturedScope == dataScopeFlow.value) {
val ackLevel =
if (wsRequest != null) {
// HermesWsClient.request() completes with the RAW JsonElement.
// Tests and a few injected callers may provide an already-decoded map.
val map =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
else -> null
}
val map = result?.toAny() as? Map<*, *>
val resKey = map?.get("key") as? String
val resVal = map?.get("value") as? String
if (resKey != "reasoning" || resVal.isNullOrEmpty()) {
@@ -37,6 +37,8 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.ConfigGetParams
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
import com.m57.hermescontrol.data.ws.contract.PromptBtwParams
import com.m57.hermescontrol.data.ws.contract.RpcMethod
import com.m57.hermescontrol.data.ws.contract.RpcMethods
@@ -694,13 +696,13 @@ class ChatViewModel(
ioDispatcher = ioDispatcher,
uiState = _uiState,
runtimeSessionId = { runtimeSessionId },
wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) },
wsSend = { params, onSent -> wsClient.send(RpcMethods.CONFIG_SET, params, onSent) },
trackRequest = { id, method -> trackRequest(id, method) },
addAssistantMessage = { text -> addAssistantMessage(text) },
handleSlashCommand = { cmd -> handleSlashCommand(cmd) },
fetchContextUsage = { fetchContextUsage() },
onModelSwitchInitiated = { onModelSwitchInitiated() },
wsRequest = { method, params -> wsClient.request(method, params).await() },
wsRequest = { params -> wsClient.call(RpcMethods.CONFIG_SET, params) },
).apply {
attachScopeObserver(viewModelScope)
}
@@ -3846,14 +3848,10 @@ class ChatViewModel(
viewModelScope.launch(ioDispatcher) {
try {
val result =
wsClient
.request(
WsMethods.CONFIG_GET,
mapOf(
"key" to "reasoning",
"session_id" to sessionId,
),
).await()
wsClient.call(
RpcMethods.CONFIG_GET,
ConfigGetParams(key = "reasoning", sessionId = sessionId),
)
val map = rpcResultMap(result) ?: error("Invalid reasoning config.get response")
val value = (map["value"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown"
val display = (map["display"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown"
@@ -3901,13 +3899,13 @@ class ChatViewModel(
viewModelScope.launch(ioDispatcher) {
try {
val params =
buildMap<String, Any> {
put("key", "reasoning")
put("value", parsed.value)
put("session_id", sessionId)
parsed.scopeName?.let { put("scope", it) }
}
val result = wsClient.request(WsMethods.CONFIG_SET, params).await()
ConfigSetParams(
key = "reasoning",
value = parsed.value,
sessionId = sessionId,
scope = parsed.scopeName,
)
val result = wsClient.call(RpcMethods.CONFIG_SET, params)
val map = rpcResultMap(result) ?: error("Invalid reasoning config.set response")
val responseKey = map["key"] as? String
val responseValue = map["value"] as? String