Project active run-journal recovery snapshots into a non-mutating compact transport form before returning them from /api/session, removing duplicate representations while preserving the live message timestamp. Thanks @allenliang2022.
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
* fix(settings): _SETTINGS_DEFAULTS theme/skin never reach a user with no settings file
`_read_raw_settings_file()` returns `{}` for a MISSING settings.json. `{}` is a
dict, so `load_settings()` always took its `isinstance(stored, dict)` arms:
settings["theme"], settings["skin"] = _normalize_appearance(
stored.get("theme") if isinstance(stored, dict) else settings.get("theme"),
stored.get("skin") if isinstance(stored, dict) else settings.get("skin"),
)
`stored.get("theme")` is then None, `_normalize_appearance(None, None)` falls
through to its unknown-theme branch, and the result is ("dark", "default").
`_SETTINGS_DEFAULTS["theme"]` and `["skin"]` are therefore unreachable for the
one case they exist to serve: a user who has no settings file yet.
This is invisible on the shipped defaults, because dark/default is exactly what
the fallback produces — the two paths agree, so nothing ever caught it. It only
surfaces once the defaults are changed, at which point the dict silently does
nothing: a deployment (or fork, or a future change to the shipped values) sets an
appearance and every new user still gets dark.
The fix falls back to the defaults instead of to None, gated on the PAIR of
keys. Per-field (`or settings.get(...)`) looks equivalent and is not: with a
stored legacy theme and no stored skin, `slate` normalises to ("dark", "slate"),
but per-field fallback injects the default skin and returns ("dark", "default"),
silently dropping the legacy migration. That is the same absent-vs-explicit
distinction the boot script draws in #6808 — this is its server-side half. #6808
stops the browser fabricating a choice; without this, the server still answers
with the fallback rather than the configured default.
Behaviour on stock defaults is provably unchanged: `_normalize_appearance(None,
None)` and `_normalize_appearance("dark", "default")` both return
("dark", "default"). A stored preference is untouched in every case.
13 tests. Both directions are pinned, and both were checked by injection:
reverting api/config.py fails exactly the two "defaults are honoured" cases and
nothing else; substituting the per-field variant fails 8, including four of the
five legacy migrations. Ran the full settings/appearance surface — 61 test files,
873 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Release exp-v0.52.178: honor configured appearance defaults for users with no settings file (#6834)
---------
Co-authored-by: Hermes Agent <hermes@aip.de>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* test(#6630): isolate auxiliary client fixtures
* test(#6630): route both aux-title suites through one scoped module stub
* test(#6630): drop dead import and record the patch.dict restore constraint
* Release exp-v0.52.177: scope auxiliary-title test module stubs, stop cross-file sys.modules leak (#6661)
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(profiles): start list cache TTL after row build
Slow row builds currently consume the cache TTL before the result is stored.
Timestamp the cache at completion so every successful build receives the full TTL.
Refs #5364
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.6-luna
* Release exp-v0.52.176: start profile list-cache TTL after row build (#6846)
---------
Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
The previous revision stripped the '@provider:' qualifier from model_value
in _session_model_state_from_request BEFORE _resolve_compatible_session_model_state
ran. That defeated two behaviors that rely on the qualified form:
1. provider-switch repair - a session pinned to a removed/unconfigured
provider (@removed:mistral-large) no longer reverted to the active
default; it returned (bare_model, removed) and targeted an unavailable
provider.
2. PR #6718's custom-provider normalizer, which needs the still-qualified
value to map @custom:<slug>:<model> to the canonical slug.
Keep the qualifier through session-state resolution (matching master) and
strip it to the bare model ONLY at the gateway request boundary via
_gateway_model_field(), which already runs on both gateway request sites.
Red-before-green: the rewritten test asserts @removed:mistral-large repairs
to the active default; it failed on the prior head and passes now.
The gateway-body assertions (TestGatewayRequestBodiesCarryBareModel) still
prove only the outbound payload is bare.
Verified: 275 tests pass across the issue-6722, provider-mismatch,
model-resolution-fast-path, gateway-approval (legacy+runs), gateway-routing,
custom-provider, session-model-switch and model-not-found suites.
Pins the provider/model pair for every qualified-model shape at all
three layers that reach the gateway: the routes splitter, the provider
normalizer, the request-state builder, and both gateway request bodies
(legacy chat completions + runs API).
The table-driven cases include the shapes a positional colon split gets
wrong -- @custom:backup:model-a, @custom:backup:model-a:free,
@custom:<host>:<port>:model and @ollama-cloud:model:tag -- and assert
the routes splitter agrees with config._parse_provider_qualified_model_id()
exactly, so re-introducing a local split heuristic fails loudly.
The gateway assertions read the JSON actually written to the request
body rather than mocking the helper under test.
All 12 fail on master before the fix and pass after it.
A provider-qualified model string (@provider:model) is deliberately kept
intact for internal routing (#1253), but three places on the way to the
Hermes gateway mishandled it:
* _clean_session_model_provider returned the whole "provider:model"
value as the provider, so the gateway saw a provider named
"ollama-cloud:deepseek-v4-flash" -> "Unknown provider".
* _session_model_state_from_request extracted the provider correctly
but left the full "@provider:model" string as the model value.
* both gateway request builders sent that string verbatim as
body["model"], which the upstream provider API 404'd on.
Rather than add positional colon-split heuristics, route all three
through config._parse_provider_qualified_model_id() -- the existing
shared grammar that already distinguishes a multi-segment custom
provider ID (custom:<slug>, custom:<host>:<port>) from a model segment
carrying its own colon tag (:free, :0731). A positional split cannot
tell those two shapes apart, so any single split direction breaks the
other; the shared parser is the only thing that gets both right.
_split_provider_qualified_model now delegates to that parser too, which
fixes the same class of truncation at all of its call sites in routes.py
instead of only the two reported ones, and guarantees the provider used
for the routing decision matches the model sent in the request.
Fixes#6722
The model picker (api/config.py:7308-7314) places the singular ``model``
field first, then appends unique IDs from the ``models`` catalog with
dedup. The Providers card was doing the opposite — dropping the singular
model entirely when a non-empty catalog existed.
This caused a silent disagreement: the picker showed the sticky default
model first, while the Providers card omitted it. Verified empirically
with ``model: sticky-default, models: {Best, Fast}`` — picker returned
the sticky model, card did not.
Now the card uses the same sticky-before-plural ordering as the picker:
singular model first (stripped), then _configured_model_ids appended
with dedup.
Updated tests:
- test_singular_model_appears_first_before_dict_models (was
test_dict_models_ignore_legacy_model_field): now asserts sticky-first
ordering instead of exclusion
- test_whitespace_and_duplicate_keys_normalized: updated for sticky-first
prepend
Replace the manual if/elif/elif model-collection chain with a call to
the shared _configured_model_ids normalizer from api/config.py. This
ensures the Providers card applies the same whitespace stripping, empty-
ID dropping, and de-duplication as the model picker — closing a gap
where keys like "Best", " Best ", and " " would produce duplicate
or empty-looking pills on the card while the picker reported a clean
catalog.
The refactored fallback also guards against whitespace-only singular
model fields, preventing empty pills from the fallback path.
Added two regression tests:
- whitespace/duplicate key normalization
- whitespace-only model field produces no entries
Greptile identified a real inconsistency (P1): when models is an empty
dict and a singular model field exists, the Providers card showed zero
models while the model picker (get_available_models) still surfaced the
configured default model via its separate default_model path.
Fix: gate both the list and dict branches on truthiness so an empty
container falls through to the singular model fallback — matching the
picker's behaviour and ensuring the card always shows at least the
configured default model.
Updated test_empty_dict_falls_through_to_model_field accordingly.
Address nesquena-hermes review feedback:
- Rename test_dict_models_include_default_model_field →
test_dict_models_ignore_legacy_model_field. The old name/docstring
claimed the singular model field appears in the list, but the
assertion required it to be absent. Now the contract is explicit:
when models is a dict, the legacy model field is NOT appended.
- Add test_dict_models_no_duplicate_when_model_is_also_key: the old
fixture used a model value not present in the dict, proving only
that the field is ignored — not that duplicates are prevented when
it matches a dict key. The new test sets model=Best with Best also
as a dict key, and asserts exactly one entry with stable ordering.
- Strengthen assertions to check list equality (insertion order)
rather than just set membership, since the provider card output
is user-visible.
Address Greptile review feedback: the test name said "falls through to
model field" but the assertion requires an empty list (no fallback).
Renamed to accurately describe the behavior — an empty dict yields
zero entries, and the model field is NOT used as a fallback.
The `models` field in a custom_providers config entry can be a list or a
dict. The dict shape is the standard format produced by `hermes config set`:
custom_providers:
- name: litellm
model: Coding
models:
Best: {context_length: 128000}
Coding: {context_length: 1000000}
Fast: {}
get_providers() only handled the list shape (#6127), so the Providers
settings card showed just the single `model` field instead of the full
catalog. The model picker (get_available_models in api/config.py) already
handled both shapes via _configured_model_ids — only the providers card
was broken.
Add a dict branch alongside the existing list branch, with regression
tests covering dict, list, default-model interaction, and empty-dict
edge cases.
Three sites used `with sqlite3.connect(f"file:...?mode=ro", uri=True) as conn:`,
which only scopes the transaction — sqlite3.Connection's context manager never
calls close(). Same recurring bug class as #1421/#1494/#2233/#3904. On a
long-lived server each poll leaked a state.db (+ -wal/-shm) fd until the
process hit macOS's 256 soft FD limit and writes started failing.
Wrap all three in contextlib.closing(), matching the pattern already used
elsewhere in these files.