Each of these carried its own removal instruction ("remove when > 2wks
old"), and every locked version is now older than min-release-age, so
resolution succeeds without them. Produced by the lint's own fixer
rather than by hand.
website/.npmrc is swept for the first time here; two of its entries
(undici, @nous-research/image-size) matched no locked package at all,
which no clock expires.
`npm ci` in both projects returns exit 0 with a byte-identical
lockfile. That covers the vite / rolldown / @oxc-project/types group,
whose comment warned that dropping it too early fails the whole install
with ETARGET.
npm reads the project's own .npmrc, $HOME, and the global config. It
does not walk up parent directories (npm/npm#11437 is still open), so
the root .npmrc gates the root install and nothing else. Two nested
projects were resolving with no supply-chain quarantine at all:
scripts/whatsapp-bridge and plugins/platforms/photon/sidecar. Both now
carry their own gate, and `npm ci` in each returns its lockfile
byte-identical.
A project is a directory holding both package-lock.json and
package.json -- the pair npm resolves against. A lockfile with no
manifest beside it is a vendored artifact; nix/ keeps one for a
hash-pinned fetch that never consults npm config.
The required floor is read from the root .npmrc instead of hardcoded,
so the standard is raised in one place. A nested project may be
stricter, never weaker.
The autofix workflow was the only caller left on the v4 SHA; the other
20 upload sites across .github/workflows use v7. The paired download
stays on v4, matching docker.yml and osv-scanner.yml, which already run
that combination.
An override forces a version on every transitive consumer, so a ranged
override re-floats packages the manifest never names. That is a wider
hole than a ranged direct dependency, and the lint did not look at it.
Overrides nest, so the walk is recursive. `npm:<pkg>@<version>` aliases
are now recognised too, and held to the same rule on their version
tail; before this they were rejected outright even when exact.
Three ranges are pinned to close the lint on the live tree. yauzl and
the bridge's protobufjs take their locked versions; the root protobufjs
override is dormant (no protobufjs resolves anywhere in the root tree)
and takes its own declared floor. Re-resolving both lockfiles with
`npm install --package-lock-only` returns them byte-identical.
An unanchored key regex matched inside a longer key with the same
suffix: removing `h2` from `{ python-h2 = false, h2 = "..." }`
produced `{ python- }`. The structural verifier caught the unparseable
result and refused the write, so no file was corrupted -- but the fixer
raised, and `run.py --fix` is deliberately not continue-on-error, so
the autofix job would go red on main and stay red.
A word boundary is insufficient: \b does not fire between `-` and `h`.
Use a lookbehind that rejects word characters, dots, and hyphens.
Both edit paths and both TOML forms are covered; reverting the anchor
fails the new tests.
- no-hardcoded-hermes-home: the Known Pitfalls rule (source of 5 bugs
in PR #3575). flags Path.home()/'.hermes' + expanduser variants in
production code; a get_hermes_home()/HERMES_HOME mention within 8
lines counts as a guard hint (fallbacks and deliberate HOME anchors
like the desktop-ssh token root pass untouched), '# hermes-home: ok'
is the explicit override. found and fixed one real bug: the telegram
adapter's gmail-triage script path ignored the active profile.
- no-ansi-erase-eol: \033[K leaks as literal ?[K under prompt_toolkit
patch_stdout; comments documenting the pitfall are exempt.
- skill-frontmatter-standards: the HARDLINE authoring rules that were
review-only — description <=60 chars/ends with period/no marketing
words, name present, platforms: required when skill scripts import
POSIX-only primitives. live tree already clean on all three.
the rebase onto main merged 12299ca54 (keep review-gated files out of
the autofix patch) textually but stitched the produce-patch step badly:
one stale js-fix.patch filename and the old grep guard in place of the
derived one. re-merged properly — the EXCLUDES pathspec strips
review-gated files (package manifests, eslint configs, .github/) from
the patch entirely so the bot PR never gates itself, then the derived
guard (js/ts/json + .py + lint fix_touches globs, lockfiles carved out)
validates what remains.
five new registered lints, all blocking, none autofixable:
- engines-satisfiable: from tests/test_engines_satisfiable.py, which
lived in the python pytest lane — a package.json-only PR never ran
it, the exact classifier gap that let the npm-12 floor outage ship.
- no-shadowed-test-definitions: from the pytest guard; same AST scan,
per-file annotations instead of one aggregated assert.
- no-committed-infographics: from infographic-check.yml; deletes the
workflow and its ci.yml wiring (one fewer runner per PR).
- workflow-sha-pins: the AGENTS.md action-pinning policy (commit SHA,
never tag/branch), previously enforced only by review. repo is
already clean — every uses: ref is pinned.
- pyproject-dep-bounds: the AGENTS.md upper-bound policy (>=floor,<
ceiling; git deps SHA-pinned) plus the pin-consistency invariant
pulled out of tests/test_packaging_metadata.py (the lazy_deps-coupled
tests stay put). self-referential hermes-agent[extra] includes exempt.
- packagejson-exact-versions: every tracked package.json dependency
must be an exact version or file:/link:/workspace: ref — ranges float
onto minutes-old releases via unreviewable lockfile churn. fixed the
one violator (whatsapp-bridge: express/pino/qrcode-terminal caret
ranges pinned to their locked versions, lockfile mirror regenerated).
autofix bot also gains ruff check --fix (continue-on-error like npm
run fix; ruff-blocking stays the PR gate), with .py added to the
patch-guard allowance.
reading was always plain tomllib — the inline-only restriction was a
write-side artifact. the [tool.uv.exclude-newer-package] section form
is the same one-key-per-line shape the fixer already edits in uv.lock,
so both files now share one section editor; plan() reads entries via
tomllib regardless of form. the loud 'unsupported form' failure is
gone — both TOML shapes are managed.
the python twin of npmrc-stale-exclude, for [tool.uv]
exclude-newer-package. two findings, both autofixable:
- 'pkg = false' disables the age gate for that package forever — an
unbounded hole. replaced with the uv-documented explicit timestamp:
the locked version's newest upload-time (from uv.lock) + 1 day, so
the exception admits exactly the release it was added for.
- entries whose locked versions have all outgrown the exclude-newer
span (+1d grace) are removed; so are entries matching no locked
package.
no network: publish dates come from uv.lock's upload-time fields;
versions without one fail open as young. names are PEP 503-normalized
to bridge pyproject's huggingface_hub and the lock's huggingface-hub.
the fixer edits BOTH files, mirroring the change into uv.lock's
[options.exclude-newer-package] so uv lock --check stays green
without re-resolution (admitted locked versions are unchanged by
construction; verified against the real lock). before each write a
tomllib-based verifier strips the exclude-newer-package data from old
and new and refuses unless everything else is structurally identical
— a lock diff that touches a dependency url/hash aborts the fix.
live run on the repo: vercel + huggingface_hub entries removed (both
locked >14d), nemo-relay + h2 converted from false to timestamps.
guard: package-lock.json slipped through the .json extension allowance
(a hole inherited from the old js-autofix guard). rather than a global
deny list, the allowance itself now excludes lockfile basenames — they
fall through to the default rejection like any other unallowed path. a
bot-authored, auto-merged lockfile rewrite is exactly the shape of a
supply-chain attack.
fixer: before writing, verify the new .npmrc is the original minus
deletions of exclude lines, comments, and blanks only — every surviving
line must appear in the original in order, and every dropped line must
be an exclude entry, comment, or blank. any other delta (touched
directives, edits, insertions) is a fixer bug and aborts the write.
a min-release-age-exclude[] exists to let resolution pick a release
younger than min-release-age. once every locked version of the
package is itself older than the gate (+1d boundary grace), the
exclude is dead weight and a standing hole in the age gate — this
lint automates the 'remove when > 2wks old' instruction those
entries' comments already carry.
matches exclude patterns (exact, @scope/*) against the sibling
lockfile's locked versions and checks registry publish dates. fails
open per pattern on any fetch error or unknown date — never removes
an exclude on partial data. network lint, so advisory on PRs by
policy; enforcement is the autofix pass on main. the fixer drops a
group's comment block only when no exclude in the group survives.
'# lint: keep' marks intentional standing excludes; applied to
@assistant-ui/* and the ink blocks in .npmrc. also removes the five
already-stale entries the first live run found (react-router,
eslint, @eslint/*, tar, concurrently — each locked >14d).
discovery fix: register lint modules in sys.modules before exec so
@dataclass can resolve annotations.
the on-merge fixer now runs npm run fix plus the registered lint
fixers (scripts/lints/run.py --fix). two-job security split unchanged:
unprivileged patch generation, privileged patch apply, bot branch
renamed bot/js-autofix -> bot/autofix.
the patch guard's allow-list is now derived — JS/TS/JSON sources for
npm run fix plus each lint's declared fix_touches globs via
--print-fix-globs — instead of a hardcoded extension regex. per-lint
attribution (a fixer writing a file only another lint's globs allow)
is enforced inside run.py --fix, which fails the job before a patch
is produced; the workflow guard is the coarse second layer.
paths filter dropped: lint inputs span the whole tree and the run
exits with an empty patch when there's nothing to fix. still no
schedule trigger — merges to main are the clock.
run_fixes now snapshots the working tree around every fixer and
validates each change against that lint's OWN fix_touches — a fixer
that writes a file only another lint's globs allow is an error, and
under-reported paths are attributed to the fixer that made them.
snapshot uses git status --porcelain so created files count too.
replaces the windows-footguns job in lint.yml — that checker now runs
through the lint registry along with subprocess-stdin (previously
wired to no workflow at all).
standalone workflow, not a job in lint.yml: ci.yml only calls lint.yml
when the classifier says python changed, and lint inputs span the
whole tree (.npmrc, package.json, workflows, ...) — a npmrc-only PR
would have skipped the lints entirely. always-on is the point: it
closes the classifier-gap class where a lint's inputs don't match the
lane that runs it. cheap because the runner and all lints are
stdlib-only (no uv/deps setup).
findings land in the PR review comment via review-status-project-lints;
blocking findings gate merge via all-checks-pass.
lints declare severity (blocking/advisory) and fixability (autofix)
explicitly. runner gates PRs only on blocking unfixable findings;
fixable ones are advisory because the autofix bot resolves them on
merge. first two registered lints wrap the existing checkers:
windows-footguns (was its own workflow job) and subprocess-stdin
(was wired to no workflow at all).
The subprocess import test was creating .tmp-hermes-exec-ask-import/
in the repo root without cleanup. Switch to pytest's tmp_path fixture
so the temp directory is auto-cleaned and never appears as untracked.
Regression tests for silent pending_approval when ask-mode leaks into
interactive CLI, plus a Path-typed hermes_constants mock so the
slash-worker profile_home test survives per-file isolation.
HERMES_EXEC_ASK (and gateway platform markers without a notify callback)
were short-circuiting interactive CLI into silent pending_approval, so
the Approve/Deny panel never appeared. Prefer the registered CLI callback
when present, and set HERMES_EXEC_ASK only in start_gateway so importing
gateway.run from CLI tools cannot poison the process.
test_slash_worker_accepts_profile_home mocks hermes_constants with
get_hermes_home=MagicMock(return_value="/tmp/hermes_test"), a str. In
production get_hermes_home() returns a Path, and hermes_state.py's
module-level DEFAULT_DB_PATH = get_hermes_home() / "state.db" does path
division. Under the str mock that becomes str / str, so importing
tui_gateway.server inside the patch raises TypeError and the test fails on
every main run (slice 4). Wrap the mock return in Path(...) so it matches the
real return type. Test-only; no production code change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Salvage of #79604 (webtecnica) + #85721 (pierrenode), combined and
rebased onto current main with simplify-code findings folded in.
#79604: update_session_model() wrote the model name to sessions.model
but never persisted the provider into model_config. On resume, the
runtime recombined the persisted model with the config.yaml primary
provider (which may not serve that model), producing auth errors.
Fix: add optional provider parameter to update_session_model, merged
into model_config via the shared _merge_model_config_json helper (not
hand-rolled SQL). Wire both gateway /model call sites to pass
result.target_provider.
#85721: session_gateway_runtime() had no billing_provider fallback.
A CLI session that never ran /model has no gateway_runtime or
top-level provider in model_config — billing_provider (written on
every session's first accounted API call) is the only durable record.
Fix: add billing_provider as the last-resort fallback in
session_gateway_runtime(), filtering bare billing buckets (auto/custom)
that are not routable identities.
Simplify-code findings addressed:
- Use _merge_model_config_json instead of 40 lines of branched SQL
- Share _BARE_BILLING_PROVIDERS from hermes_state.py (was duplicated
as a set in tui_gateway/server.py)
- Merge None-filtering from #85920 with the billing_provider fallback
into one coherent return path
Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
Three widenings for capabilities UIs (Bot Mode's bot builder):
1. profiles.create share_auth (default false): skip the auth.json
COPY so the new profile reads OAuth/token state through the
existing global-root fallback and refreshes write through to it.
A copy forks token state — the first refresh on either side
invalidates the other for single-use refresh tokens; sharing keeps
ONE live token pool for the main profile and every bot. Static
.env keys still copy (no refresh semantics). Receipt:
mirrored.auth = 'shared'.
2. profiles.describe reports mcp_servers
[{name, enabled, transport}] from the profile's config.
3. profiles.configure accepts enabled_mcp_servers (replace
semantics): toggles via the standard disabled flag; enabling a
server the profile lacks copies its definition from the launch
profile's catalog (names never invented). Launch catalog read
BEFORE the home override flips config resolution.
E2E: describe keys include mcp_servers; create with share_auth ->
mirrored.auth='shared' + no auth.json in the profile dir; configure
applied.mcp_servers=true.
54cc39aa15 (distrust foreign pricing for custom providers) tested with
openai/gpt-5.5-pro fixtures; 83d373aae6 (salvaged #70324) made that exact
id warn unconditionally as a known-confusion model. Each was green alone;
together the distrust tests fail on every main run (slice 6).
Use a neutral fixture id for the distrust tests and add a regression test
pinning the composed behavior: the id-keyed nudge survives custom-provider
pricing distrust.
Clicking an agent in a multi-profile roster pays the entire backend
spawn + WebSocket dial cost on first open — several seconds of
'loading' (Bot Mode report). Expose the existing pool-only primitive
(openGatewayForProfile: opens/pools the socket WITHOUT activating it,
already no-ops for the primary and shared-remote routes) as
host.warmProfile(name) so rosters can pre-dial after mount and the
first click lands on a live socket. Fire-and-forget by design;
failures stay silent — the real open path re-runs its own ensure.
Follow-ups on top of the salvaged #70324:
- _confirm_startup_expensive_model_override evaluates the unified
registry (combined_selection_warning) so id-keyed guards like the
data-training-tier warning fire at startup too, not just the cost guard.
- The Termux-adjacent light oneshot fast-path (added after the PR
branched) ran _run_and_exit_oneshot without the guard — same bug
class, third sibling site now covered.
Run the expensive-model warning for explicit startup `-m` / `--provider`
overrides before the chat loop starts, and fail closed for non-interactive
invocations that select an expensive or known-confusing model.
Also classify Nous paid-model 404s that say credits are required as billing
exhaustion so they fail fast with billing guidance.
Tested:
- scripts/run_tests.sh tests/hermes_cli/test_cli_startup_model_cost_guard.py tests/hermes_cli/test_model_cost_guard.py tests/agent/test_error_classifier.py -- --tb=short -q
Custom providers (custom:xxx) serve their own pricing; models.dev stores
OpenRouter prices for the same model ids. The cost guard fired on that
foreign pricing and blocked composer/CLI model switches on custom
providers with a wildly wrong warning (#54348).
expensive_model_warning now only trusts model_info/models.dev pricing
when the provider maps to a models.dev provider and the info's
provider_id matches, and only consults the pricing-entry lookup when
the billing route is known. Salvaged from #54422; the PR's desktop-hook
half predates the use-model-controls rewrite and is superseded by the
hook's existing rollback handling.
The command path (/model <name> --provider <p>) called
_confirm_expensive_model_switch() inline. That modal blocks its calling
thread on a response queue (see _prompt_text_input_modal); on the
prompt_toolkit main thread the TUI event loop freezes, the modal never
renders, and the switch silently cancels after the 120s timeout — the
user sees a frozen terminal and 'Model switch cancelled.' without ever
seeing the warning. The picker path already dispatched confirm+apply on
a worker thread; the command path now mirrors that contract.
Extract the inline confirm+apply block into
_confirm_and_apply_cli_model_switch() (preserving --once restore and
persist semantics) and dispatch it on a daemon thread when a TUI app is
present, keeping the synchronous path for non-interactive/test use.
Tests: new test_model_switch_confirm_thread.py pins (a) confirm runs off
the main thread when _app is present, and (b) the no-app path stays
synchronous. Existing _StubCLI helpers forward to the extracted method.
Plain type=completion events built in _run_process_watcher carried only
session_key (chat/thread routing) with no spawning-session stamp, so after
/new (or a session switch) a completion notification from the OLD session
was injected into the chat's NEW session. Main already solved this exact
class for async delegations via the _classify_completion_target pre-flight
(_USER_BOUNDARY_END_REASONS drop on user-closed sessions, deliver on
idle-ends, follow the compression-tip chain), but the gate only ran for
type=async_delegation events.
Kernel salvage of #16455:
- Stamp the spawning conversation's session-db id (HERMES_SESSION_ID via
session-scoped env) on the ProcessSession and the pending_watchers entry
at spawn time in tools/terminal_tool.py; persist it through the process
registry checkpoint/restore so recovered watchers keep the stamp.
- Thread the stamp into the completion_evt built by _run_process_watcher
(watcher entry first, ProcessSession fallback for recovered watchers).
- In _deliver_completion_notification, run the SAME pre-flight classifier
for stamped type=completion events: terminal -> drop with a log (output
stays available via process(action='log')), retry -> False so the
watcher re-polls, deliver -> proceed. The policy has exactly one owner
(_classify_completion_target); nothing is forked. Unstamped legacy
events keep today's deliver-always behavior, and the async-delegation
path is untouched.
Based on the session-boundary approach from #16455 by @Tosko4 (original PR
was over-scoped across adapters/slash-commands/cron; this lands the kernel
only).
Tests: completion from a /new-closed session is dropped; completion after
an idle-end still delivers; unstamped legacy event delivers; retry verdict
returns retryable False without adapter injection; async_delegation gate
unchanged; stamp survives checkpoint recovery.
The bare dimmed glyph had nothing to read against. Over a light document in
a light theme it is a pale mark on white, and every rest opacity tried
(0.35, then 0.45, then 0.75 behind a text halo) came back reported as the
button being gone.
Give the control its own substrate, which is what every shipped overlay
does: Apple's HIG puts controls on a material rather than directly on
content, Firefox picture-in-picture draws close/unpip as opaque chips, and
Discord's overlay adds a contrast layer over the game. Deriving contrast
from the backdrop is not available to us either way -- mix-blend-difference
composites against the page, and behind a transparent Electron window that
is nothing.
The chip now wears the composer bar's own tokens (fill, hairline, radius,
bottom shadow), so it inverts with the theme and with the OS appearance
under mode 'system'. It rests hidden and fades in while the bar, the band,
or the chip itself is hovered, with a hold on the way out so it survives the
reach across the gap -- reaching for the HUD is the motion that means "I
want the app". Hover rather than focus: the caret gate behind #81893 broke
the escape hatch exactly when it was needed.
The async-delegation watcher drained the completion queue as a batch but
then delivered each event as its own synthetic turn, flooding the session
when a fan-out of background subagents finished together. Builds on the
per-process completion batching salvaged from PR #71898 (thanks
@yuzilongleif-collab) which coalesces concurrent _run_process_watcher
completions behind a short per-route fan-in window.
This commit adds the async-delegation half: group the drained batch by
full routing key (session_key + parent_session_id + platform/chat/thread/
user) and inject ONE consolidated turn per group. Durable-ack handling
stays honest: sibling rows are claimed up front via claim_event_delivery;
rows another consumer owns are excluded from the consolidated text (no
double-delivery); sibling claims are acknowledged only after adapter
acceptance and released (still pending) on failure. Events for different
sessions never coalesce, and a single-event group rides the existing
per-event path unchanged (latency and text identical).
Tests: 3 same-tick events -> exactly one adapter.handle_message carrying
all 3 results with all 3 durable rows delivered; 2 sessions -> 2 turns;
single-event path unchanged; failed batch releases claims and retries;
foreign-claimed sibling excluded and left pending.
Async-delegation batch completions and background watch notifications
re-enter the gateway as synthetic MessageEvent(internal=True) turns via
_inject_watch_notification, but were persisted as bare role='user' rows —
indistinguishable from real user input in transcripts and the desktop UI.
Thread the event's internal flag through to persistence: when
event.internal is set, the turn's persisted user row is stamped
display_kind='internal_notification' (the existing DB-only presentation
sidecar used by auto_continue / model_switch rows). Wired through
_run_agent → _run_agent_inner → TurnContext → run_conversation's
persist_user_display_kind, and onto the three gateway-side fallback user
rows (transient failure, no-new-messages, pre-run crash), whose
append_to_transcript writer now forwards display_kind/display_metadata
to SessionDB.append_message.
Invariants preserved: role stays 'user' (alternation untouched), no new
injections, no past-context mutation, and display_kind is already popped
from every provider-bound copy in conversation_loop, so replayed sessions
never leak the marker to the API.
Regression tests: internal turn marked, real user turn unmarked, fallback
rows marked/unmarked per event, and a DB round-trip proving replay keeps
role/content intact while the provider copy drops the marker.
The terminal redactor is called without force=True on all three
user-facing sends in _run_process_watcher, so process output reaches the
platform raw when security.redact_secrets is disabled. The agent-notify
path was already covered; this covers the two direct adapter.send()
paths the sweeper identified.
Review: teknium1 (#73547)
_output passes through redact_terminal_output (force=False) but
not _redact_gateway_user_facing_secrets (force=True). When
security.redact_secrets is disabled or the command is not an
env-dump, the output can leak credentials (Authorization: Bearer,
PGPASSWORD=, etc.) into the session transcript and chat platforms.
Add the same redaction gate applied to _command so both paths
are protected by force=True + _GATEWAY_SECRET_PATTERNS.
Add TestNotificationRedaction class with two tests:
1. test_completion_notification_redacts_secret — verifies _move_to_finished
redacts API keys in completion notifications before enqueueing
2. test_watch_match_notification_redacts_secret — verifies _check_watch_patterns
redacts secrets in watch_match notifications before enqueueing
These tests cover the gap identified in #43025 where the explicit process
tool path (poll/log/wait) was redacted but the automatic notification
delivery path was not.
Apply _redact_process_result() to completion and watch_match
notifications before enqueuing them in the completion_queue.
Previously, the explicit process tool path (poll/log/wait) applied
redact_terminal_output() via _redact_process_result(), but the
automatic notification delivery path (notify_on_complete, watch_patterns)
only applied strip_ansi(). This meant API keys, tokens, and other
secrets from background process output were injected into the LLM
conversation unmasked.
The fix ensures both code paths apply the same redaction, matching
the foreground terminal tool behavior.
format_process_notification had no case for watch_overflow_tripped /
watch_overflow_released, so a watch-pattern notification flood surfaced
as '[IMPORTANT: Background process exited (exit code ?)]' — a phantom
exit notification for a process that never existed — while the actual
'watch flood, N notifications suppressed' summary in the event's
message field was silently dropped. The gateway delivery path was
worse: _drain_gateway_watch_events retained only watch_match and
watch_disabled, discarding overflow events entirely before formatting.
Route both event types through the message field in the shared
formatter and the gateway formatter, and retain them in the gateway
drain.
Adds hermes_cli/model_selection_guards.py: a single evaluation point that
runs every selection guard (cost + the new data-policy guard) and returns
the warnings that fired. All seven model-selection surfaces (CLI picker,
cli.py TUI modal, gateway typed /model, dashboard web_server, TUI gateway,
Telegram and Discord pickers) now call the registry instead of importing
model_cost_guard directly — so the data-training-tier warning from
PR #81416 fires everywhere at once, and future guards need zero surface
wiring.
Guard modules keep their public APIs; existing mock patch points
(hermes_cli.model_cost_guard.expensive_model_warning) remain valid.
muse-spark-1.2-contributor is heavily discounted BECAUSE Meta uses your
prompts and completions to train future models. Selecting it for the price
without realising the data trade-off is a footgun.
Add hermes_cli/model_data_policy_guard.py (mirrors model_cost_guard):
data_training_warning(model_id, provider, base_url) -> DataTrainingWarning|None,
driven by a vendor-agnostic rule table. The status is not machine-readable on
/v1/models or models.dev, so the v1 rule keys on the documented '-contributor'
model id (fires regardless of provider, so it also covers custom/gateway
routes). Message mirrors Meta's pricing-doc language and figures
(https://dev.meta.ai/docs/pricing-rate-limits/).
Wire it into the CLI model picker's confirm flow (auth.py) as a [y/N]
disclosure, chained after the expensive-model cost guard. Fires only on the
contributor tier; silent on muse-spark-1.1/1.2 and all other models.
The route dict in _persist_model_switch_to_session used filtering
(omits falsy values) while the top-level keys used (writes
explicit None to trigger deletion in _merge_model_config_json). This
asymmetry meant stale keys from a previous /model switch survived in
the nested gateway_runtime dict even after the fix in #85261 that
properly deleted them from the top-level keys.
Fix: build the route dict with and derive the top-level keys
from **route so both shapes always use identical deletion semantics.
Also filter None values in session_gateway_runtime's reader since
gateway_runtime is replaced as a whole dict (not deep-merged), so None
values written by the persist path survive in the nested dict.
Found by /simplify-code 3-reviewer review on #85261 (all 3 reviewers
converged on the route dict asymmetry as the verdict-relevant finding).