The provider entries of OPTIONAL_ENV_VARS are descriptive metadata, but
they came from list_providers(), and provider discovery reads config. A
partial discovery could also be published permanently.
They are now built from providers.bundled_profiles(), which executes each
bundled provider plugin into a private module and collects its
registrations: no config, no discovery, no registry change. The result is
a plain idempotent cache (concurrent first callers compute the same pure
result). Discovery's completion hook adds the process-wide plugin
providers it registered, read from the registry as it stands. Disabled
plugins are never executed for the catalog.
Under plugins.isolation: host, _import_hosted_plugin_dir had no "already
imported" guard, unlike the in-process path (_exec_plugin_module checks
sys.modules). Every rescan of the home layer -- an unsettled plugin-dir
stamp (the dir was created within the 2 s mtime tick) once the 1 s stamp
TTL lapses, or another plugin dir landing while the process runs -- ran
the whole hosted import again: another host extraction, fresh proxy
objects replacing the ones callers already hold, and the in-process
warning for a create_client profile logged again.
main's test_create_client_profile_loads_in_process_and_its_sibling_stays_hosted
hit it on the Windows arm64 runner (assert 3 == 1): the plugin host's
first start there took over a second, so the next lookups landed past the
TTL with the stamps still unsettled and rescanned.
The layer now records the module names it holds as hosted proxies and
skips them on a rescan; a failed host extraction or in-process import is
not recorded, so the next scan retries it as before.
Every cache keyed on a file's stat signature had the bug the config caches had: a same-size
in-place rewrite in one coarse timestamp tick keeps mtime, ctime, size and inode, so the older
parse was served for the rest of the process. Move the helper to utils.file_cache_version
(file_signature + content digest while the file is under 2 s old; always_digest for signatures
compared much later, where the racy digest going empty would read as a change) and key every
cache that can hold a secret, token, pairing/allowlist, route or config on it:
- auth.py: global auth-store memo, Nous auth-status memo (was mtime only)
- gateway/pairing.py approved-users cache (a revocation kept access)
- run_profile_reconcile: served-profile signature (always_digest: a false change reconnects
adapters) and the hook-consent signature
- webhook dynamic routes (route HMAC secrets), Feishu comment pairing/rules cache (mtime only)
- MCP OAuth tokens-file reload check (mtime only; always_digest: a reload tears the session down)
- config parse-failure record (always_digest), named-custom-provider memo, model aliases,
gateway.standalone memo, profile model read, MoA preset, agent raw config,
tui_gateway raw config cache, onboarding due stamp, free-tier inventory stamp,
provider model-list credential fingerprint, git safe.directory key, skills prompt
manifest
config_cache_version takes its stat through config_version, so its errors and seams are unchanged.
Left alone: the tool-definition cache key and the platform manifest stamp (a safe worker must
never open config.yaml or a plugin file; the MoA preset cache is skipped in one instead) and the
CLI MCP watcher seed (pinned to file_signature by a main test).
A scan slower than one mtime tick let a stamp read before it look settled
afterwards and get remembered over an install that landed mid-scan. The same-tick
test now drives a slow scan (red on the previous head), and layer_env freezes
time_ns so a paused runner cannot make the fresh stamps look settled.
The plugin-dir check compares the mtimes of plugins/ and plugins/model-providers/
against the stamps read at the last scan. A kernel without fine-grained
timestamps gives every directory change within one clock tick (~4 ms) the same
mtime, and FAT/HFS+ round to 1-2 s, so an install landing in the tick the last
scan read leaves the stamps equal: the new provider stayed "unknown" until some
later change to those dirs, TTL or not. This is the CI-only failure of
test_new_install_found_by_new_name_and_after_the_ttl (late-b and late-a are
installed microseconds apart; local 6.13+ kernels stamp a just-stat'ed dir
fine-grained, so it only reproduced on the runners).
A stamp still within one tick of the wall clock is now left unremembered, so
the next periodic check (once per TTL) scans again until the stamps settle;
settled stamps are trusted exactly as before, so the steady state still costs
one stat pair per TTL and no rescans.
Every auth-registry read now consults the bound home layer, so a caller whose
home lookup hands back a str (the TUI protocol test harness stubs
hermes_constants that way) reached the plugin-dir stat with str / str.
Two homes registering the same provider shared one auth-registry row: a
home's plugin rewrote that row's endpoint and credential env names in place,
so after A->B->A home A kept B's endpoint and env names, and a home without
the plugin read another home's override of a built-in row. Catalog aliases
went into one process-wide table with setdefault, so the first home kept an
alias another home gives to a different provider.
PROVIDER_REGISTRY now derives each read from the bound home's profiles: a
$HERMES_HOME plugin's fields land on that home's own copy of the row (built
from its profile when no process-wide profile backs it), an alias key follows
the home's own claimant, and a process-wide registration still rewrites the
stored row (every home reads it). The catalog alias table overlays the bound
home's catalog_aliases per read instead of copying them in.
registry_lookup re-reads after its sync whatever the count says: a home's
first plugin scan mirrors its rows in the sync's own nested list_providers()
pass, so the first lookup of a just-bound home returned None.
Every field this branch added to ProviderProfile followed main's positional
layout as a positional field, so a dataclass plugin subclass passing its own
option after the released arguments assigned it to account_card instead.
A KW_ONLY sentinel after model_capabilities (main's last field) keeps every
released slot and the subclass's own options where they were.
The compat test now freezes the released positional layout as a literal
instead of deriving it from the new class, which could not catch the shift.
The per-home provider layer (review P1-1) re-scanned $HERMES_HOME/plugins on lookup, so a --safe-mode
worker read user plugin manifests through the aux client path. Gate it on safe_worker_enabled(), the same
gate _discover_providers already uses. Caught by test_canonical_safe_launch_is_frozen_policy_executed_in_isolated_worker.
recover_for_provider's own profile lookup stamps the home's plugin dirs and remembers the miss;
an install that finished within the 1 s stamp TTL (a fast or cached catalog install) was then
invisible to the resolution that triggered it and its immediate retries, which answered "Unknown
provider ... installed but failed to load" with the wrong advice. A successful install now forces
a rescan of the bound home's plugin dirs and forgets its misses (providers.refresh_bound_home).
_load_error read the general plugin manager, which lists a model-provider dir with no error: providers
discovery (_exec_plugin_module) imports it and only logged the failure. And a user-dir model-provider
plugin loads without plugins.enabled, so gating "failed to load" on plugins.enabled told an unlisted
broken plugin to run `hermes plugins enable`, a no-op.
Providers discovery now records each $HERMES_HOME plugin dir's import error on that home's layer (the
registry is per profile home), cleared by the next successful import, and plugin_load_error reads it.
provider_install_hint: present + no profile + in plugins.disabled -> enable; otherwise it failed to
load, with the recorded error and the update command.
53a51a49cc made every attempt of a user turn's first API call (429/5xx retries, fallback
activations) re-send the provider's user_initiated request headers. Hermes has always sent them
on the first attempt only (retries went out with the route's default), and that difference is
billing-visible (premium-request initiator headers), so it is reverted: the first payload built
in a user turn consumes the flag and is the only user-initiated one.
Kept from 53a51a49cc: the flag is consumed exactly once whatever the route answers, so a route
without attribution or a failing request_headers hook never leaves it set for a tool-loop
follow-up.
Under plugins.isolation: host, profile_call encodes kwargs without callable refs, so the
is_suppressed / explicitly_configured lambdas arrived in the host as non-callable Opaque values
and a hosted provider seeded nothing (while _profile_seeds_pool still disabled generic env
seeding). The seam now passes data: suppressed_sources (frozenset) and explicitly_configured
(bool), computed only for profiles that override the seam.
Shell-exported GH_TOKEN-style names are always stripped from child envs, so a hosted provider
that declares them (env_vars + ambient_credentials) never saw a shell-only token. The plugin host
env now re-adds exactly those names through get_secret under the host home's scope: the launch
home keeps its shell value, a routed home gets only its own .env value, never the launch one.
R5 (r4-labs/CORE_GAPS.md): with no credential, every surface said only "No usable credentials
found for provider X" (runtime resolution adds the env-var list; CLI/Desktop readiness not even
that), and an ACP agent's auth-required error reached the user bare, so a plugin could not name its
own sign-in. Two generic seams, unset = today's text:
- ProviderProfile.missing_credentials_hint() -> str | None, appended by runtime resolution
(_api_key_provider_runtime) and both readiness checks (runtime_readiness, tui_gateway
methods_config) via runtime_readiness.missing_credentials_suffix.
- ACPClient.auth_required_hint (class attr, None) appended to a request's JSON-RPC error when its
code is ACP's auth-required (-32000).
Other users: any OAuth/device-code plugin provider; ACP CLIs with their own login (kimchi-acp).
R4 W6. _supports_reasoning_extra_body asked the host-claiming profile
(profile_for_host) before the LM Studio / Ollama Cloud / OpenRouter gates, and
_build_chat_completions_kwargs swapped the generic reasoning body for the
profile's payload on any claimed host. A plugin listing openrouter.ai or
ollama.com in hostnames for catalog/aux routing (without overriding
reasoning_payload) silently turned reasoning off on those routes.
_reasoning_endpoint_profile() is the one lookup for both: it returns the
claiming profile except on endpoints core gates itself (Nous Portal, Vercel AI
Gateway, Ollama Cloud, OpenRouter, the lmstudio provider). Documented on
ProviderProfile.hostnames. No in-tree profile claims those hosts; Copilot hosts
are unaffected.
R4 P2-5 (wire W2).
- replay_message_carriers was asked once per carrier-bearing assistant message
on every request (O(history) calls, each an IPC round trip under
plugins.isolation: host). Its answer is now kept per (route, carrier key set)
as a key mapping and applied to every message with that key set; a hook whose
answer rewrites values is still asked per message. Contract documented on the
hook in providers/base.py.
- reasoning_payload and replays_function_thought_signature join request_headers
/ max_tokens_param as memoized pure hooks (argument values frozen, answers
deep-copied).
- ONE failure policy for every per-request profile hook
(provider_route_hooks.ask_profile, also behind route_answer): a raising hook is
logged once per (profile, hook) and answers the generic value; the agent (scope)
remembers it, so the rest of the session keeps the generic answer and request
bytes never flip between turns. Before: _supports_reasoning_extra_body
swallowed per request, _endpoint_reasoning_payload / replay_message_carriers /
replays_function_thought_signature raised out of the kwargs build, route_answer
swallowed per call. A failure mid-request reshapes the whole request so it
matches every later one. One-shot auxiliary calls (no agent) ask again.
- x-initiator turn flag: already consumed before asking the route (53a51a49cc).
R4 P2-1 (wire W1). Under plugins.isolation: host, one profile overriding
create_client() made the whole credential-free extraction raise
PluginHostUnsupported, so every profile of that plugin vanished (the
github-copilot plugin lost both copilot and copilot-acp).
The extraction now classifies profile by profile: hostable profiles become
plugin-host proxies as before; a profile that cannot cross the boundary is
reported with its reason, and discovery imports the plugin in-process with
registration restricted to exactly those names (_REGISTRATION_ONLY), logging
one warning per such profile. Hosted siblings stay hosted.
R4 review ACP-3: ProviderProfile.process_command_fallback and
_process_command_provider served a "pinned delegation.command" that no
config key produces. override_acp_command only comes from creds["command"],
i.e. the runtime of the external-process provider that resolved it, so the
selected provider was always kept at the first branch; the binary-stem match
and the fallback flag were reachable only from direct unit calls.
Remove the field, the stem matching and their developer-guide rows. A
pinned command now always keeps the selected provider and api_mode (base
rerouted non-external selections to the hard-coded copilot-acp slug; a
same-binary profile no longer captures the child either). probe_deleg:
delegation.provider kimchi-acp / kimchi-agent still resolve to kimchi-acp,
as on base. The labs plugin's process_command_fallback=True must go too.
R4 CR-4. The docstring (and the plugin guide row) said "None = generic env seeding", but
agent/credential_pool.py::_profile_seeds_pool skips the generic env loop whenever the hook is
OVERRIDDEN, whatever it returns. Honouring a None return would mean calling the hook a second
time from _seed_from_env (it validates/exchanges tokens: I/O) or re-plumbing the singleton/env
seeder results, so the contract is documented as implemented: overriding owns seeding, return
[] for none; only the base implementation keeps generic env seeding. The existing
tests/agent/test_provider_credential_seams.py pins that behaviour.
R4 H4. Registry ids with no profile (openai-api, xai-oauth, tencent-tokenplan) are looked up by
per-request route hooks, and every get_provider_profile miss forced a plugin-dir re-stat,
defeating the 1 s stamp TTL (2 plugin-dir stats per call on top of the config.yaml signature).
Each home layer remembers when a name's miss last forced a check; the same name forces again
only after the TTL. A NEW name still forces at once, so a provider installed while the process
runs is found on its first request; runtime registrations need no re-stat.
p_miss: route_answer(openai-api) os.stat/call 3.00 -> 1.00 (the remaining stat is the
config.yaml signature behind the named-custom-provider memo, the same edit signal load_config uses).
Test: tests/providers/test_profile_miss_ttl.py (no re-stat within TTL; new install found by new
name and after TTL; runtime registration found despite a cached miss); red on the base.
R4 H2 (creds CR-2). _provider_alias_table merges the bound home's plugin aliases but was
memoized once per process: the first home's aliases served every profile ('github' normalized
differently by call order) and a plugin installed while the process ran never reached it.
Memoize per home key, rebuilt when the provider registry generation moves, through
providers.bound_registry_key() (home key + generation after the periodic plugin-dir check),
the same invalidation profile_for_host uses. auxiliary_client.py net -2 lines.
Test: tests/agent/test_aux_alias_home_scope.py (both orders; install while running); red on the base.
R4 P1-1 (hosts H1 / creds CR-1). A $HERMES_HOME model-provider plugin mirrors its
profile into the process-wide PROVIDER_REGISTRY, and the mirror never goes away. In a
process serving several profiles (multiplex gateway, Desktop serve), a profile WITHOUT
the plugin then saw the row: env auto-detect (whose ambient_credentials skip reads the
bound home's profiles) selected it from a stray token, and the generic env ladder
returned that raw token for the plugin's endpoint.
PROVIDER_REGISTRY is now a HomeScopedRegistry: plain dict storage, but every read
(get / [] / in / iteration / items) hides a plugin-mirrored row (and alias keys pointing
at it) whose provider has no profile in the bound home. One filter covers
canonical_registry_rows, registry_lookup, resolve_provider, _env_key_auto_detected,
resolve_api_key_provider_credentials, runtime resolution and setup_migration's
credential scan. Built-in rows cost one set lookup; mirrored rows ask
providers.names_without_profile (no discovery, no miss re-scan). _insert_row rebuilds
from the raw storage so rows hidden from the bound home survive a reorder.
Test: tests/providers/test_registry_home_scope.py, an A->B->A->B two-home E2E in one
process with a generic fake ambient-credential plugin (red on the base for the absence
case and setup detection).
A provider's request_headers(user_initiated=True) answer (turn attribution,
e.g. a premium-billing initiator header) was consumed when the first
attempt's payload was built. A 429/5xx retry or a fallback activation
rebuilds the payload for the SAME API call, so the retried attempt went out
with the route's default (agent) attribution instead of the user's.
The route now claims the call's api_call_count; every attempt of that call
re-applies the headers, tool-loop follow-ups (higher count) do not, and a new
turn / session reset clears the claim.
Repro (fake profile + local HTTP stand-in, scratch probe_xinit.py): first
attempt carries the header, the retry did not (codex_responses 500 retry,
chat_completions 429 retry); both attempts carry it after this change.
The turn flag is consumed exactly once per user turn, before the route is
asked: a route with no attribution, or a request_headers hook that fails
(route_answer swallows plugin errors), no longer leaves it set, which billed
the next tool-loop call as user-initiated (r4-review P2-5).
After the auth registry rows moved to the plugin mirror, the alias keys the mirror
adds made a provider-declared overlay look like a lossy collapse, so "copilot" and
"github-copilot-acp" resolved through the bare registry rung (wrong transport,
lost base-URL env var). Rows that all share the overlay profile's own row now
keep the built-in chain. Which names reach the overlay is declared by the new
overlay_aliases field instead of every profile alias, matching the old alias groups.
The Copilot rows in the picker catalog (CANONICAL_PROVIDERS, PROVIDER_GROUPS,
model-catalog aliases, _AGGREGATOR_PROVIDERS), the providers-layer overlays
(HERMES_OVERLAYS, _ALIAS_GROUPS, _LABEL_OVERRIDES) and the models.dev id map
become generic ProviderProfile fields the copilot / copilot-acp plugins set:
- picker_after: the row lands after that slug (order in `hermes model` kept)
- picker_group: (group_id, label, description) fold in interactive pickers
- catalog_aliases: profile aliases also normalize in the model catalog
- aggregator: joins _AGGREGATOR_PROVIDERS at sync time
- provider_overlay: HermesOverlay fields for the models.dev-keyed definition
(hermes_overlays() merges them; normalize_provider/get_provider read them)
- models_dev_id (existing): read at lookup time by models_dev_id_for /
models_dev_provider_map, never merged into PROVIDER_TO_MODELS_DEV
The picker's copilot live-catalog branch was equivalent to the generic one
and is dropped; the dead "copilot" auth type leaves _ACCOUNTS_AUTH_TYPES.
hermes_cli/models.py no longer carries any Copilot code (2977 -> ~2640 lines): the live /models
catalog + caches, context windows, id normalization + alias table, reasoning-effort helpers,
catalog-token fallbacks and editor headers move to plugins/model-providers/copilot/catalog.py;
the copilot-acp session memo moves to plugins/model-providers/copilot-acp/catalog.py. The curated
copilot / copilot-acp rows leave models_catalog_static.py.
Core reaches them through generic ProviderProfile seams (providers/base.py, I2-cat block):
- picker_catalog(force_refresh): a provider-owned picker catalog, tried in the slot of a built-in
_PROVIDER_CATALOG_FETCHERS row (presence = a live source, so the static tail stays a placeholder)
- static_models: curated ids registered as the provider's core static-catalog row
(sync_plugin_provider_catalog), so detection/defaults/validation keep seeing them
- probe_models(base_url, api_key, timeout): probe_api_models asks the host's profile
(profile_for_host, metadata hosts included) before the generic HTTP probe; the dead
api.githubcopilot.com header branch behind the old early return is gone
- invalidate_catalog_cache(): /model --refresh drops provider-owned memos
- catalog_credential_files: credential files folded into every cached catalog's fingerprint
(same slot/order as before, so fingerprints are byte-identical)
Plugin callers import from .catalog; tests re-targeted (patch.object on the plugin modules),
plugin-only test files moved under tests/plugins/model_providers/.
The core PROVIDER_REGISTRY rows and alias-map rows for copilot / copilot-acp are
deleted; the plugin->registry mirror (auth_plugin_providers) now supplies them
from the profiles, so with the plugin absent copilot is an unknown provider and
GH_TOKEN can no longer auto-detect it.
Generic profile fields (providers/base.py, I2-auth block) fill the slots the
mirror lacked:
- registry_auth_type: row auth_type when credentials resolve differently from
the setup flow auth_type names (copilot: "copilot" flow, api_key row)
- base_url_env_var: base-URL override env var not listed in env_vars
- registry_after: keep the row's place in priority-ordered scans (aux api-key
chain, env auto-detect)
The mirror also writes alias keys pointing at a plugin's row; scans now see each
provider once (canonical_registry_rows): env auto-detect (an ambient plugin was
auto-detected through its alias key), `hermes auth list` / provider pickers, and
credential_lifecycle env-var -> provider mapping (seeded pools under alias names).
DEFAULT_GITHUB_MODELS_BASE_URL / DEFAULT_COPILOT_ACP_BASE_URL had no other users;
the plugins carry the literal base URLs.
providers/base.py auth_type values and the client-construction hook's pointer
(now the model-provider plugin developer guide), gateway/session_cli_turn.py and
tools/schema_sanitizer.py comments no longer name a specific provider.
- providers.profile_for_host is the single host->profile lookup (declared hostnames, exact or
subdomain), memoized per (hermes_home_key, host, metadata) and invalidated by a registry
generation counter bumped on every registration (process-wide or per-home layer).
- agent/provider_route_hooks.route_profiles = named profile, then profile_for_host(base_url);
replaces L1's every-profile scan, L2's conversation_loop._credential_refresh_profile host scan
and L3's agent/endpoint_profile.py + ProviderProfile.serves_endpoint (deleted).
- Copilot hostnames gain models.github.ai (the L3 Responses/reasoning gate host); per-site
differences (carriers, editor headers, initiator) stay in the plugin hooks keyed on base_url.
- Pure per-request hooks (request_headers, max_tokens_param) are memoized per argument set so a
plugins.isolation: host proxy pays one IPC call per request shape.
- hermes_cli/models.py: delete _should_use_copilot_responses_api / copilot_model_api_mode;
runtime_provider routes per-model providers through api_mode_for_model generically.
- credential_refresh_notice seam restores main's Copilot refresh status lines.
- auth._env_key_auto_detected: ambient-credential check reads list_providers once instead of a
per-pid get_provider_profile miss that walked the provider ladder (welcome_trial regression).
Core branched on the Copilot slug/host for four wire decisions. Replace them with
generic ProviderProfile hooks (providers/base.py L1 block) and move the Copilot
logic into plugins/model-providers/copilot/wire.py:
- api_mode_for_model(model, *, api_key=None): run_agent Responses routing, aux
CodexAuxiliaryClient wrap, model_switch.model_derived_api_mode (copilot rows
of _PROVIDER_API_MODE_OVERRIDES removed).
- client_headers(*, base_url, api_key, has_images): agent_init/_client_lifecycle
host tables, the client-kwargs chokepoint guard (_ensure_route_client_headers),
the image request-client header swap, both aux header branches.
- request_headers(*, base_url, model, user_initiated): first-call-of-a-user-turn
attribution in turn_api_request and MoA reference slots.
- max_tokens_param(*, base_url, model): run_agent._max_tokens_param and
auxiliary_max_tokens_param.
agent/provider_route_hooks.py asks the named provider's profile first, then any
registered profile overriding the hook, so custom routes at a provider's host
keep its quirks. Plugin absent = generic behaviour.
Core no longer branches on the Copilot/GitHub Models hosts in the Responses adapter, transports,
reasoning params or reasoning-carrier replay. A provider profile claims its backend through
ProviderProfile.serves_endpoint(base_url) (default False) and fills generic L3 fields/hooks:
responses_replay_item_ids, responses_prompt_cache_key, responses_issuer_kind,
responses_trusts_completed_status, responses_native_compaction, responses_profile_reasoning,
message_carrier_opaque/_text, replays_tool_call_signatures, reasoning_payload(),
replay_message_carriers(), replays_function_thought_signature().
The in-tree copilot plugin fills them from plugins/model-providers/copilot/responses.py, keeping the
persisted "github_responses" issuer stamp and hermes.native_assistant record keys. Persisted carrier
records whose declaring plugin is gone are stripped by their own keys.
Parity: an offline oracle over 2079 Copilot cases (main request kwargs incl. preflight, aux Responses,
carrier shaping/capture, reasoning gate, native compaction) is byte-identical to the base on the agent
paths in both registry states.
A pinned delegation.command used to force provider "copilot-acp" whenever the selected
provider was not external_process. It now resolves the external_process profile whose
process_command names the same binary, else the profile flagged with the new
ProviderProfile.process_command_fallback (copilot-acp sets it, so every Copilot setup
resolves as before), else the spawn fails loudly. A kimchi binary now lands on kimchi-acp.
The env-writer denylist's hardcoded HERMES_COPILOT_ACP_* pair becomes
hermes_cli.auth_external_process.external_process_launch_env_names(): every registered
external_process profile's process_command_env_vars + process_args_env_var. This also
closes the gap for out-of-tree providers (KIMCHI_ACP_COMMAND/ARGS) and COPILOT_CLI_PATH,
which select the executable too.
The Desktop Accounts tab only offered in-app device-code login to four hardcoded
providers (_DEVICE_CODE_STARTERS) and carried a hand-written copilot-acp card; the
`hermes model` flows for copilot / copilot-acp and the effort-picker branch lived in
core. They now go through generic ProviderProfile fields (providers/base.py, L6 block):
- account_card / account_status: a profile customises (or adds) its Accounts card.
- start_device_login / poll_device_login / save_device_login: data-only device-code
hooks; core owns the wait loop, cancellation and the profile-scoped atomic save, so
/api/providers/oauth/<id>/start and /poll serve any profile that fills them.
- model_setup_flow: a provider's own `hermes model` step ahead of the generic one.
- setup_reasoning_efforts: the setup effort picker's per-model levels.
The copilot plugin fills them in setup.py (flow moved verbatim; Desktop device login
reuses the terminal flow's OAuth app, RFC 8628 polling and COPILOT_GITHUB_TOKEN), and
copilot-acp keeps its card/flow via its own setup.py. setup_migration reads the
profile's ambient_credentials flag (lane L2) instead of a slug. Review-bot comment
attributions in voice code are reworded.
New generic hook normalize_model_id(model, *, api_key="") -> str | None (None = core's generic rules),
consulted by model_normalize.normalize_model_for_provider, models_validate (catalog-membership lookup)
and the CLI startup repair in cli_model_switch_mixin, which re-derives api_mode through the existing
model_switch.model_derived_api_mode. The copilot plugin answers from its alias table/catalog;
copilot-acp delegates without forwarding its ACP marker key. The Copilot-only locale key
cli.model.normalized_copilot_model goes; the generic cli.model.normalized_model is used.
Generic seams (providers/base.py, lane L4 block):
- ProviderProfile.hostnames / metadata_hostnames: hosts a profile claims for URL->provider inference.
- providers.profile_for_host(url, *, metadata=False): lazy, per-home lookup over those claims
(exact host or subdomain, never substring).
- ProviderProfile.models_dev_id (declared; consumers follow).
- ProviderProfile.live_context_length(model, *, api_key, base_url): credentialed live catalog window,
consulted at the same step (after the persistent cache) the Copilot branch ran.
Core loses the Copilot rows in model_metadata._URL_TO_PROVIDER, the auxiliary pool/auth-refresh host
tables and the dead _STRICT_VISION_BACKENDS row (never reached: only nous and the auto-order providers
go through it). The in-tree copilot plugin claims githubcopilot.com (+ GitHub Models metadata hosts)
and answers live_context_length from its catalog; copilot-acp delegates to it.
A header callback that reads another unread LazyHeaders (a derived
User-Agent) re-entered the plain Lock on the same thread and hung; in
host isolation the extraction timed out and the plugin vanished. RLock;
the override test gains a derived header (red on de37426: gmi-tag None
under host isolation). Concurrent first reads still resolve once (7 git
spawns for 16 threads).
Parallel clients reading default_headers for the first time each ran the
resolver, so 16 threads spawned 112 git subprocesses (16 version walks)
instead of 7. Double-checked lock around resolution; the reviewer probe
now shows 7 spawns, no unresolved values.
Review finding (P2, #144640): LazyHeaders was a read-only Mapping, so a
user provider override doing get_provider_profile('gmi').default_headers.copy()
(or |, update, json.dumps, ==) raised AttributeError and the provider
disappeared, in-process and under plugins.isolation: host.
LazyHeaders is now a dict subclass that stores every key up front and
resolves callable values (the release-carrying User-Agent) on first value
read. Every value-reading/mutating method resolves first; __iter__ is
overridden so dict(x)/{**x}/f(**x) leave CPython's raw-storage fast path;
json.dumps takes items() because the stored keys make it non-empty.
.copy()/|/copy/pickle yield plain resolved dicts. Importing provider plugins
still runs no git. The plugin-host Mapping materialization is gone: encode()
reads a dict subclass through items(), which resolves.
Every CLI start imports all provider plugins (hermes_cli.config lists their env vars), and
fireworks/gmi/xai/router/kimi/opencode plus auth_constants, models, model_catalog,
auxiliary_client, gemini_native_adapter, web_server_messaging, slack, yuanbao and perplexity
built a `<product>/<release>` string at module level. In a source checkout the release comes
from the 7-call git provenance walk (`git tag --merged HEAD` alone is ~0.35 s in a large
clone), so `hermes sessions list` paid it for headers it never sends.
Provider profiles take a LazyHeaders mapping (resolved on first read by the client builders,
which already copy with dict()); core modules call version_info.hermes_user_agent() where the
request is built. Header values are unchanged.