diff --git a/agent/credential_pool.py b/agent/credential_pool.py index cd64a15a1a7a1..e8d88071d557d 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -552,19 +552,34 @@ def custom_provider_pool_key_candidates( """ if not base_url: return [] + from hermes_cli.custom_provider_identity import iter_custom_provider_routes, match_custom_provider_route normalized_url = _norm_url(base_url) - requested_aliases = _requested_custom_name_aliases(provider_name) if provider_name else set() - - if requested_aliases: - for norm_name, entry in _iter_custom_providers(): - if requested_aliases & _custom_entry_name_aliases(norm_name, entry): - return _pool_keys_for_custom_entry(norm_name, entry) - - for norm_name, entry in _iter_custom_providers(): - entry_url = _norm_url(entry.get("base_url")) - if entry_url and entry_url == normalized_url: - return _pool_keys_for_custom_entry(norm_name, entry) - return [] + entries = [entry for _name, entry in _iter_custom_providers()] + routes = list(iter_custom_provider_routes(entries)) + if provider_name: + matched = match_custom_provider_route(provider_name, entries) + # An explicit name must never fall through to a sibling at the same URL. + if matched is None: + return [] + else: + matched = next(((slug, entry) for slug, entry in routes + if _norm_url(entry.get("base_url")) == normalized_url), None) + if matched is None: + return [] + slug, entry = matched + if _norm_url(entry.get("base_url")) != normalized_url: + return [] + keys = [] + provider_key = _normalize_custom_pool_name(str(entry.get("provider_key") or "")) + if provider_key: + keys.append(provider_key) + keys.append(slug) + # Retain old display-name pools only if that alias belongs to this group. + legacy = f"{CUSTOM_POOL_PREFIX}{_normalize_custom_pool_name(str(entry.get('name') or ''))}" + legacy_match = match_custom_provider_route(legacy, entries) + if legacy_match is not None and legacy_match[0] == slug: + keys.append(legacy) + return list(dict.fromkeys(keys)) def get_custom_provider_pool_key(base_url: Optional[str], provider_name: Optional[str] = None) -> Optional[str]: @@ -592,8 +607,9 @@ def _get_custom_provider_config(pool_key: str) -> Optional[Dict[str, Any]]: """Return the custom_providers config entry matching a pool key like 'custom:together.ai'.""" if not pool_key.startswith(CUSTOM_POOL_PREFIX): return None - suffix = pool_key[len(CUSTOM_POOL_PREFIX):] - return next((entry for norm_name, entry in _iter_custom_providers() if norm_name == suffix), None) + from hermes_cli.custom_provider_identity import match_custom_provider_route + matched = match_custom_provider_route(pool_key, (entry for _name, entry in _iter_custom_providers())) + return matched[1] if matched is not None else None def get_pool_strategy(provider: str) -> str: @@ -641,21 +657,7 @@ def _legacy_custom_pool_matches( ) -> bool: """Match a legacy ``custom:`` pool against a named runtime identity.""" try: - for normalized_name, entry in _iter_custom_providers(): - if f"{CUSTOM_POOL_PREFIX}{normalized_name}" != pool_provider: - continue - aliases = {normalized_name} - for value in (entry.get("name"), entry.get("provider_key")): - alias = _normalize_custom_pool_name(str(value or "")) - if alias: - aliases.add(alias) - if alias.startswith(CUSTOM_POOL_PREFIX): - aliases.add(alias[len(CUSTOM_POOL_PREFIX):]) - configured_url = _norm_url(entry.get("base_url")) - runtime_aliases = {_normalize_custom_pool_name(provider_norm)} - if provider_norm.startswith(CUSTOM_POOL_PREFIX): - runtime_aliases.add(_normalize_custom_pool_name(provider_norm[len(CUSTOM_POOL_PREFIX):])) - return bool(runtime_aliases & aliases) and runtime_url == configured_url + return pool_provider in custom_provider_pool_key_candidates(runtime_url, provider_name=provider_norm) except Exception: return False return False @@ -747,10 +749,9 @@ def _accepts(candidate: str) -> bool: # Named/exact custom runtimes are keyed by identity: search the # configured candidates by identity before endpoint so a sibling # sharing the URL cannot lend its pool. - for normalized_name, entry in _iter_custom_providers(): - for candidate in _pool_keys_for_custom_entry(normalized_name, entry): - if _accepts(candidate): - return candidate + for candidate in custom_provider_pool_key_candidates(base_url, provider_name=provider_norm): + if _accepts(candidate): + return candidate except Exception: pass return provider_norm diff --git a/gateway/slash_commands_model.py b/gateway/slash_commands_model.py index 8628c8cb678ff..be4959c12e8ac 100644 --- a/gateway/slash_commands_model.py +++ b/gateway/slash_commands_model.py @@ -16,7 +16,6 @@ from agent.i18n import t from gateway.platforms.event import MessageEvent -from hermes_cli.config import atomic_config_write from utils import base_url_host_matches logger = logging.getLogger("gateway.run") # log-record parity with gateway/run.py @@ -34,6 +33,12 @@ # /reasoning display-toggle arguments -> show_reasoning value. _REASONING_DISPLAY_TOGGLES = {"show": True, "on": True, "hide": False, "off": False} +def _picker_stale_reply() -> str: + """Text for the picker's session/authorization re-checks — the same one for the pre-commit + checks and the ones inside the commit (e.g. /new landing between them). The typed path never + needs it: mid-run input is rejected up front.""" + return t("gateway.model.picker.stale_reply") + def _model_switch_skew_guard() -> Optional[str]: """Refuse a model switch when the gateway is running stale code: a first-time lazy import on @@ -54,11 +59,26 @@ def _model_switch_skew_guard() -> Optional[str]: ) -async def _persist_model_switch_to_config(result, config_path) -> None: +async def _persist_model_switch_to_config(result, config_path, *, reasoning_effort: str = "") -> None: """Write-through a resolved /model switch to the profile config at ``config_path``, off the event loop (the route comparison can do cold-start disk I/O).""" from hermes_cli.model_switch import persist_model_selection - await asyncio.to_thread(persist_model_selection, result, config_path) + kwargs = {"reasoning_effort": reasoning_effort} if reasoning_effort else {} + await asyncio.to_thread(persist_model_selection, result, config_path, **kwargs) + + +async def _picker_guard_passes(ctx) -> bool: + """True when a picker commit may keep writing session state: no guard (the typed path holds + the switch lock across its whole commit) or the card's guard still reports its session + current. ``/new`` does not take the switch lock, so picker commits re-check at each + session-scoped write instead of trusting the check that started the commit.""" + guard = getattr(ctx, "session_guard", None) + if guard is None: + return True + try: + return bool(await guard()) + except Exception: + return False @dataclasses.dataclass @@ -72,6 +92,8 @@ class _ModelSwitchContext: one_turn: bool = False reasoning_effort: str = "" # `--reasoning ` riding with the pick (typed path only) restore_snapshot: Optional[dict] = None + session_guard: Optional[Any] = None # picker-only: re-validates the card's session mid-commit + session_stale: bool = False # set by the commit when that guard reports the session replaced current_model: str = "" current_provider: str = "openrouter" current_base_url: str = "" @@ -203,11 +225,17 @@ async def _record_model_switch( """ from hermes_cli.model_switch import format_model_for_display + if not await _picker_guard_passes(ctx): + ctx.session_stale = True + return None # Persist the new model to the session DB so the dashboard shows the updated model (#34850). _sess_db = getattr(self, "_session_db", None) if _sess_db is not None: # so the dashboard shows the updated model try: _sess_entry = await self.async_session_store.get_or_create_session(source) + if not await _picker_guard_passes(ctx): + ctx.session_stale = True + return None # Typed path: consume the auto-reset flag so the next message's cleanup does not # wipe the override stored below. if not picker and getattr(_sess_entry, "was_auto_reset", False): @@ -219,6 +247,9 @@ async def _record_model_switch( ) except Exception as exc: logger.debug("Failed to persist model switch to DB: %s", exc) + if not await _picker_guard_passes(ctx): + ctx.session_stale = True + return None # Prepended to the next user message (no system messages mid-history). Display form strips # opaque Palantir RID prefixes; the override map keeps the full ID for the wire. if not hasattr(self, "_pending_model_notes"): @@ -242,6 +273,9 @@ async def _record_model_switch( self._claim_one_turn_restore(ctx.session_key, ctx.restore_snapshot) elif not picker and hasattr(self, "_pending_one_turn_model_restores"): self._pending_one_turn_model_restores.pop(ctx.session_key, None) + if not await _picker_guard_passes(ctx): + ctx.session_stale = True + return None # A --global switch has ONE durable authority: config.yaml. Write it first; on success drop # the session override (memory + store) — a redundant copy would shadow every later global # change after a restart (#100314: a stale override resumed `gpt-5.6-sol-900k` as the base @@ -249,7 +283,8 @@ async def _record_model_switch( global_error: Optional[str] = None if ctx.persist_global: try: - await _persist_model_switch_to_config(result, ctx.config_path) + effort_kwargs = {"reasoning_effort": ctx.reasoning_effort} if ctx.reasoning_effort else {} + await _persist_model_switch_to_config(result, ctx.config_path, **effort_kwargs) except Exception as e: logger.warning("Failed to persist model switch: %s", e) global_error = f"config.yaml not updated ({str(e) or type(e).__name__})" @@ -379,6 +414,11 @@ async def _commit_model_switch_locked(self, result, ctx: _ModelSwitchContext, *, if error is not None: return error global_error = await self._record_model_switch(result, ctx, source=source, one_turn=one_turn, picker=picker) + if ctx.session_stale or (picker and not await _picker_guard_passes(ctx)): + # The card's session was replaced (e.g. /new) while this click was committing — + # nothing further may land on the replacement; the reply must not claim a switch + # that did not stick. + return _picker_stale_reply() reply = await self._model_switch_confirmation( result, ctx, one_turn=one_turn, picker=picker, global_error=global_error, ) @@ -386,12 +426,22 @@ async def _commit_model_switch_locked(self, result, ctx: _ModelSwitchContext, *, # `/model X --reasoning `: same applier as /reasoning, same scope as the pick. # The record step already evicted the cached agent, so the pin lands on the rebuild. from gateway.run import _platform_config_key - reply += "\n" + self._apply_reasoning_selection( - ctx.session_key, _platform_config_key(source.platform), ctx.reasoning_effort, - persist_global=ctx.persist_global and global_error is None) + if ctx.persist_global and global_error is None: + # The paired choice was already committed in the model's atomic write. + from hermes_constants import parse_reasoning_effort + self._reasoning_config = parse_reasoning_effort(ctx.reasoning_effort) + self._set_reasoning_override(ctx.session_key, None) + reply += "\n" + t("gateway.reasoning.set_global", effort=ctx.reasoning_effort) + elif not await _picker_guard_passes(ctx): + # The session rotated after the check above (the confirmation awaited metadata + # in between): never pin the old card's effort onto the fresh session. + return _picker_stale_reply() + else: + reply += "\n" + self._apply_reasoning_selection( + ctx.session_key, _platform_config_key(source.platform), ctx.reasoning_effort) return reply - async def _send_model_picker(self, event: MessageEvent, source, adapter, session_key: str, listing_kwargs: dict, on_model_selected) -> bool: + async def _send_model_picker(self, event: MessageEvent, source, adapter, session_key: str, listing_kwargs: dict, on_model_selected, *, persist_global: bool = False) -> bool: """Send the interactive /model picker; False when nothing was sent (text fallback). *source* is session-key-normalized so the picker's thread metadata lands where the next turn reads.""" from hermes_cli.model_switch_providers import list_picker_providers @@ -403,13 +453,33 @@ async def _send_model_picker(self, event: MessageEvent, source, adapter, session providers = [] if not providers: return False - result = await adapter.send_model_picker( - chat_id=source.chat_id, providers=providers, - current_model=listing_kwargs["current_model"], - current_provider=listing_kwargs["current_provider"], session_key=session_key, - on_model_selected=on_model_selected, - metadata=self._thread_metadata_for_source(source, self._reply_anchor_for_event(event)), - ) + picker_kwargs = {} + # Context-aware adapters opt in; old platform/plugin signatures remain valid. + if getattr(adapter, "supports_model_picker_context", False) is True: + reasoning = self._resolve_session_reasoning_config( + source=source, session_key=session_key, model=listing_kwargs["current_model"], + ) or {} + picker_kwargs["picker_context"] = { + "user_id": getattr(source, "user_id", ""), + "user_id_alt": getattr(source, "user_id_alt", ""), + "chat_type": getattr(source, "chat_type", ""), + "thread_id": getattr(source, "thread_id", None), + "profile_label": getattr(source, "profile", "") or getattr(self, "_owner_profile", ""), + "persist_global": persist_global, + "reasoning_effort": "none" if reasoning.get("enabled") is False else reasoning.get("effort", ""), + } + try: + result = await adapter.send_model_picker( + chat_id=source.chat_id, providers=providers, + current_model=listing_kwargs["current_model"], + current_provider=listing_kwargs["current_provider"], session_key=session_key, + on_model_selected=on_model_selected, + metadata=self._thread_metadata_for_source(source, self._reply_anchor_for_event(event)), + **picker_kwargs, + ) + except Exception: + logger.warning("Model picker send failed", exc_info=True) + return False return bool(result.success) async def _model_listing_reply( @@ -430,21 +500,60 @@ async def _model_listing_reply( ) adapter = self._delivery_adapter_for(ctx.source) if adapter is not None and getattr(type(adapter), "send_model_picker", None) is not None: - async def _picker_switch(model_id: str, provider_slug: str) -> str: - # The picker callback binds the raw event source (pre-normalization). - result, error = await self._perform_model_switch(ctx, model_id, provider_slug, event.source) - if error is not None: - return error - return await self._commit_model_switch(result, ctx, source=event.source, picker=True) - - async def _on_model_selected(_chat_id: str, model_id: str, provider_slug: str) -> str: + context_picker = getattr(adapter, "supports_model_picker_context", False) is True + bound_session_id = None + if context_picker: + entry = await self.async_session_store.get_or_create_session(ctx.source, touch_activity=False) + bound_session_id = entry.session_id + + async def _selection_is_current() -> bool: + if not context_picker: + return True + if self._is_user_authorized_for_source(ctx.source) is not True: + return False + if self._delivery_adapter_for(ctx.source) is not adapter: + return False + if self._session_key_for_source(ctx.source) != ctx.session_key: + return False + if profile_home is not None and self._resolve_profile_home_for_source(ctx.source) != profile_home: + return False + entry = await self.async_session_store.get_or_create_session(ctx.source, touch_activity=False) + return entry.session_id == bound_session_id + + async def _picker_switch(model_id: str, provider_slug: str, effort: str = "") -> str: + async with self._model_switch_lock(): + if ctx.session_key in getattr(self, "_running_agents", {}): + # Typed /model mid-run is rejected (busy_policy "reject" + + # _BUSY_REJECT_TEXT); a delayed card click must not hot-swap the agent + # an in-flight turn is streaming from either. + return getattr(self, "_BUSY_REJECT_TEXT", {}).get("model") or ( + "Agent is running — wait or /stop first, then switch models.") + if not await _selection_is_current(): + return _picker_stale_reply() + selection_ctx = dataclasses.replace( + ctx, reasoning_effort=effort if effort and effort != "keep" else ctx.reasoning_effort, + session_guard=_selection_is_current, + ) + selection_ctx.read_config() + selection_ctx.apply_override(self._session_model_overrides.get(ctx.session_key, {})) + result, error = await self._perform_model_switch(selection_ctx, model_id, provider_slug, ctx.source) + if error is not None: + return error + if not await _selection_is_current(): + return _picker_stale_reply() + return await self._commit_model_switch_locked(result, selection_ctx, source=ctx.source, picker=True) + + async def _on_model_selected(_chat_id: str, model_id: str, provider_slug: str, effort: str = "") -> str: + if context_picker and _chat_id != ctx.source.chat_id: + return t("gateway.model.picker.wrong_chat") if profile_home is None: - return await _picker_switch(model_id, provider_slug) + return await _picker_switch(model_id, provider_slug, effort) from gateway.run import _profile_runtime_scope with _profile_runtime_scope(profile_home): - return await _picker_switch(model_id, provider_slug) + return await _picker_switch(model_id, provider_slug, effort) - if await self._send_model_picker(event, ctx.source, adapter, ctx.session_key, listing_kwargs, _on_model_selected): + if await self._send_model_picker(event, ctx.source, adapter, ctx.session_key, listing_kwargs, _on_model_selected, + persist_global=ctx.persist_global): return None # Picker sent — adapter handles the response lines = [t("gateway.model.current_label", model=ctx.current_model or "unknown", provider=get_label(ctx.current_provider)), ""] @@ -624,16 +733,16 @@ async def _handle_personality_command(self, event: MessageEvent) -> str: # ----------------------------------------------------------- /reasoning, /fast def _save_gateway_config_key(self, key_path: str, value) -> bool: - """Save a dot-separated key to config.yaml (shared by /reasoning, /fast and their pickers).""" - from gateway.slash_commands import _nested_dict + """Save a dot-separated key to config.yaml (shared by /reasoning, /fast and their pickers). + + ONE targeted read-modify-write through the shared writer lock: only this key changes, so + a save racing a /model selection commit (or another --global command) can neither revert + the other's keys nor be reverted by it.""" from gateway.run import _gateway_config_home - from hermes_cli.config import read_user_config_raw + from utils import atomic_roundtrip_yaml_update config_path = _gateway_config_home() / "config.yaml" try: - user_config = read_user_config_raw(config_path) # raw: never persist merged defaults - *parents, leaf = key_path.split(".") - _nested_dict(user_config, *parents)[leaf] = value - atomic_config_write(config_path, user_config) + atomic_roundtrip_yaml_update(config_path, key_path, value) return True except Exception as e: logger.error("Failed to save config key %s: %s", key_path, e) diff --git a/hermes_cli/cli_model_switch_mixin.py b/hermes_cli/cli_model_switch_mixin.py index 996d448d77ad6..85e0d2d6f12ef 100644 --- a/hermes_cli/cli_model_switch_mixin.py +++ b/hermes_cli/cli_model_switch_mixin.py @@ -214,21 +214,20 @@ def _picker_offers_reasoning(provider_data: dict, model: str) -> bool: return not (isinstance(entry, dict) and entry.get("reasoning") is False) -def _apply_reasoning_after_switch(cli, effort: str, *, persist_global: bool) -> None: +def _apply_reasoning_after_switch(cli, effort: str, *, persisted: bool) -> None: """Apply a ``--reasoning `` that rode along with a model pick. Runs AFTER the swap: the agent's ``switch_model`` re-resolves ``reasoning_config`` from config.yaml, so an earlier write - would be clobbered. Session-scoped unless the pick itself persists (``--global``).""" - from cli import CLI_CONFIG, _cprint, _parse_reasoning_config, save_config_value + would be clobbered. ``persisted`` means the model and effort were saved atomically.""" + from cli import CLI_CONFIG, _cprint, _parse_reasoning_config parsed = _parse_reasoning_config(effort) if parsed is None: return cli.reasoning_config = parsed if cli.agent is not None: cli.agent.reasoning_config = parsed - saved = persist_global and save_config_value("agent.reasoning_effort", effort) - if saved: + if persisted: CLI_CONFIG.setdefault("agent", {})["reasoning_effort"] = effort - _cprint(f" Reasoning effort: {effort}" + (" (saved to config)" if saved else "")) + _cprint(f" Reasoning effort: {effort}" + (" (saved to config)" if persisted else "")) def _commit_model_switch( @@ -246,16 +245,23 @@ def _commit_model_switch( if not picker: cli._pending_one_turn_model_restore = snapshot _print_switch_summary(cli, result, old_model, one_turn=one_turn, strict_context=not picker) - if reasoning_effort: - _apply_reasoning_after_switch(cli, reasoning_effort, persist_global=persist_global and not one_turn) + saved = False if persist_global: from hermes_cli.model_switch import persist_model_selection - persist_model_selection(result) - _cprint(" Saved to config.yaml (--global)" if picker else " Saved to config.yaml") + effort_kwargs = {"reasoning_effort": reasoning_effort} if reasoning_effort and not one_turn else {} + try: + persist_model_selection(result, **effort_kwargs) + except Exception: + _cprint(" Could not save config.yaml; model and reasoning apply to this session only.") + else: + saved = True + _cprint(" Saved to config.yaml (--global)" if picker else " Saved to config.yaml") elif one_turn: _cprint(" (next turn only — restores after one response)") else: _cprint(" (session only — add --global to persist)") + if reasoning_effort: + _apply_reasoning_after_switch(cli, reasoning_effort, persisted=saved and not one_turn) # The row records what THIS session runs even on --global (else a later resume restores the # stale creation-time model); --once is restored after one turn and never touches the row. if not one_turn: diff --git a/hermes_cli/config_providers.py b/hermes_cli/config_providers.py index f0d58254f661e..4c4bb8cd5ab38 100644 --- a/hermes_cli/config_providers.py +++ b/hermes_cli/config_providers.py @@ -340,6 +340,8 @@ def get_compatible_custom_providers( def _norm(entry: Dict[str, Any], field: str) -> str: return str(entry.get(field, "") or "").strip().lower() + from hermes_cli.custom_provider_identity import custom_provider_group_key + compatible: List[Dict[str, Any]] = [] seen_provider_keys: set = set() seen_name_url_pairs: set = set() @@ -349,7 +351,7 @@ def _norm(entry: Dict[str, Any], field: str) -> str: provider_key = _norm(entry, "provider_key") name = _norm(entry, "name") base_url = str(entry.get("base_url", "") or "").strip().rstrip("/").lower() - pair = (name, base_url, _norm(entry, "model")) + pair = (*custom_provider_group_key(entry), _norm(entry, "model")) if provider_key and provider_key in seen_provider_keys: continue if name and base_url and pair in seen_name_url_pairs: diff --git a/hermes_cli/custom_provider_identity.py b/hermes_cli/custom_provider_identity.py new file mode 100644 index 0000000000000..4b9a69daa48f9 --- /dev/null +++ b/hermes_cli/custom_provider_identity.py @@ -0,0 +1,74 @@ +"""Pure identities shared by custom-provider menus and runtime resolution. + +Never resolves secrets, runs key commands, or reads configuration. Multiple model +entries on the same credential route share one slug; separate routes receive +collision-free slugs in configuration order. +""" +from __future__ import annotations + +import hashlib +import json +from typing import Any, Iterable, Iterator + + +def _fingerprint(value: Any) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest() + + +def credential_identity(entry: dict) -> str: + """Opaque identity of all configured credential sources (env names are case sensitive).""" + key = str(entry.get("api_key") or "").strip() + env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip() + if key.startswith("${") and key.endswith("}"): + env, key = env or key[2:-1].strip(), "" + return _fingerprint([str(entry.get("key_cmd") or "").strip(), key, env]) + + +def display_prefix(name: str) -> str: + return next((name.split(sep)[0].strip() for sep in ("—", " - ") if sep in name), name) + + +def custom_provider_group_key(entry: dict) -> tuple: + """Identity of a model group, independent of its model list and display suffix.""" + url = str(entry.get("base_url") or entry.get("url") or entry.get("api") or "").strip().rstrip("/").lower() + name = display_prefix(str(entry.get("name") or entry.get("provider_key") or "").strip()) + mode = str(entry.get("api_mode") or entry.get("transport") or "").strip().lower() + return (url, credential_identity(entry), mode, _fingerprint(entry.get("extra_headers") or {}), name.lower()) + + +def iter_custom_provider_routes(entries: Iterable[dict]) -> Iterator[tuple[str, dict]]: + from hermes_cli.providers import custom_provider_slug + + groups: dict[tuple, str] = {} + used: set[str] = set() + for entry in entries: + if not isinstance(entry, dict): + continue + key = custom_provider_group_key(entry) + if not key[0] or not key[-1]: + continue + slug = groups.get(key) + if slug is None: + base = custom_provider_slug(key[-1], str(entry.get("provider_key") or "")) + slug, suffix = base, 2 + while slug in used: + slug = f"{base}-{suffix}" + suffix += 1 + groups[key] = slug + used.add(slug) + yield slug, entry + + +def match_custom_provider_route(requested: str, entries: Iterable[dict]) -> tuple[str, dict] | None: + from hermes_cli.providers import custom_provider_aliases + + requested = str(requested or "").strip().lower() + routes = list(iter_custom_provider_routes(entries)) + # An allocated slug wins over another entry's ambiguous display-name alias. + for slug, entry in routes: + if requested == slug: + return slug, entry + for slug, entry in routes: + if requested in custom_provider_aliases(str(entry.get("name") or ""), str(entry.get("provider_key") or "")): + return slug, entry + return None diff --git a/hermes_cli/main_provider_setup.py b/hermes_cli/main_provider_setup.py index a1878344d6faa..4508f4f01a10d 100644 --- a/hermes_cli/main_provider_setup.py +++ b/hermes_cli/main_provider_setup.py @@ -470,9 +470,9 @@ def _custom_provider_base_url_config_value(provider_info, resolved_base_url=""): def _save_custom_provider(base_url, api_key="", model="", context_length=None, name=None, api_mode=None, - key_env=""): - """Save a custom endpoint to ``custom_providers`` in config.yaml, deduplicated by base_url (an - existing entry gets model / context_length / api_mode updated). *key_env* set means the caller + key_env="", *, provider_slug=""): + """Save a custom endpoint, matching its selected route or URL plus credential identity. + An existing entry gets model / context_length / api_mode updated. *key_env* means the caller already wrote the key to ``.env``; the entry references it instead of inlining the secret. See #69449. @@ -482,8 +482,21 @@ def _save_custom_provider(base_url, api_key="", model="", context_length=None, n providers = cfg.get("custom_providers") or [] if not isinstance(providers, list): providers = [] + from hermes_cli.custom_provider_identity import credential_identity, custom_provider_group_key, match_custom_provider_route + from hermes_cli.config import get_compatible_custom_providers, _expand_env_vars + from hermes_cli.config_providers import _normalize_custom_provider_entry + target_identity = credential_identity(_expand_env_vars({"api_key": api_key, "key_env": key_env})) + selected = match_custom_provider_route(provider_slug, get_compatible_custom_providers(cfg)) if provider_slug else None + if provider_slug and selected is None: + raise ValueError("Selected provider no longer exists; reopen the model picker") for entry in providers: - if not (isinstance(entry, dict) and entry.get("base_url", "").rstrip("/") == base_url.rstrip("/")): + if not isinstance(entry, dict) or entry.get("base_url", "").rstrip("/") != base_url.rstrip("/"): + continue + if selected is not None: + normalized = _normalize_custom_provider_entry(entry) + if normalized is None or custom_provider_group_key(normalized) != custom_provider_group_key(selected[1]): + continue + elif credential_identity(entry) != target_identity: continue changed = False if model and entry.get("model") != model: @@ -809,46 +822,28 @@ def _paste_token(prompt: str): def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: """Saved custom providers keyed by slug, with raw ``${ENV}`` refs preserved.""" - from hermes_cli.config import get_compatible_custom_providers, read_raw_config - from hermes_cli.providers import custom_provider_slug - - # Raw (un-expanded) templates keyed by identity. ``get_compatible_custom_providers( - # read_raw_config())`` is deliberately bypassed: its normalize step ``urlparse()``s - # ``base_url`` and drops entries whose base_url is itself an env-ref template. - raw_api_key_refs: dict[tuple, str] = {} - raw_base_url_refs: dict[tuple, str] = {} - raw_cfg = read_raw_config() + from hermes_cli.config import get_compatible_custom_providers, read_raw_config, _expand_env_vars + from hermes_cli.config_providers import _normalize_custom_provider_entry + from hermes_cli.custom_provider_identity import custom_provider_group_key, iter_custom_provider_routes - raw_entries: list[tuple[str, str, dict]] = [] - raw_list = raw_cfg.get("custom_providers") - if isinstance(raw_list, list): - raw_entries.extend((e.get("name", ""), "", e) for e in raw_list if isinstance(e, dict)) - raw_providers = raw_cfg.get("providers") - if isinstance(raw_providers, dict): - raw_entries.extend((e.get("name", "") or k, k, e) for k, e in raw_providers.items() if isinstance(e, dict)) - for name, provider_key, raw_entry in raw_entries: - template = str(raw_entry.get("api_key", "") or "").strip() - base_template = str(raw_entry.get("base_url", "") or raw_entry.get("url", "") or raw_entry.get("api", "") or "").strip() - name = str(name or "").strip() - provider_key = str(provider_key or "").strip() - model = str(raw_entry.get("model", "") or raw_entry.get("default_model", "") or "").strip() - # Index by every identity the loaded (expanded) config might present: (name), - # (name, model), (provider_key), (provider_key, model); case-insensitive names. - keys = [k.lower() for k in (name, provider_key) if k] - identities = [(k,) for k in keys] + [(k, model) for k in keys] - for refs, tmpl in ((raw_api_key_refs, template), (raw_base_url_refs, base_template)): - if "${" in tmpl: - for identity in identities: - refs.setdefault(identity, tmpl) - - def _lookup_ref(refs: dict[tuple, str], name: str, provider_key: str, model: str) -> str: - name_lc = str(name or "").strip().lower() - pkey_lc = str(provider_key or "").strip().lower() - model = str(model or "").strip() - return next((refs[i] for i in ((pkey_lc, model), (pkey_lc,), (name_lc, model), (name_lc,)) if i[0] and i in refs), "") + # Match raw templates to the expanded credential route, never name/URL alone: + # two accounts may have identical names, endpoints and default models. + raw_cfg = read_raw_config() + raw_entries = [("", entry) for entry in raw_cfg.get("custom_providers", []) if isinstance(entry, dict)] + raw_entries += [(key, entry) for key, entry in (raw_cfg.get("providers") or {}).items() + if isinstance(entry, dict)] + route_refs = {} + for key, raw in raw_entries: + normalized = _normalize_custom_provider_entry(_expand_env_vars(raw), provider_key=key) + if normalized is None: + continue + api_ref = str(raw.get("api_key") or "").strip() + url_ref = str(raw.get("base_url") or raw.get("url") or raw.get("api") or "").strip() + route_refs[custom_provider_group_key(normalized)] = ( + api_ref if "${" in api_ref else "", url_ref if "${" in url_ref else "") custom_provider_map = {} - for entry in get_compatible_custom_providers(cfg): + for route_slug, entry in iter_custom_provider_routes(get_compatible_custom_providers(cfg)): if not isinstance(entry, dict): continue name = (entry.get("name") or "").strip() @@ -857,7 +852,9 @@ def _lookup_ref(refs: dict[tuple, str], name: str, provider_key: str, model: str continue provider_key = (entry.get("provider_key") or "").strip() model = entry.get("model", "") - custom_provider_map[custom_provider_slug(name, provider_key)] = { + api_ref, url_ref = route_refs.get(custom_provider_group_key(entry), ("", "")) + custom_provider_map[route_slug] = { + "provider_slug": route_slug, "name": name, "base_url": base_url, "api_key": entry.get("api_key", ""), @@ -870,8 +867,8 @@ def _lookup_ref(refs: dict[tuple, str], name: str, provider_key: str, model: str "discover_models": entry.get("discover_models", True), "api_mode": entry.get("api_mode", ""), "provider_key": provider_key, - "api_key_ref": _lookup_ref(raw_api_key_refs, name, provider_key, model), - "base_url_ref": _lookup_ref(raw_base_url_refs, name, provider_key, model)} + "api_key_ref": api_ref, + "base_url_ref": url_ref} return custom_provider_map diff --git a/hermes_cli/model_setup_flows_custom.py b/hermes_cli/model_setup_flows_custom.py index ef444622ece91..360df56e45c66 100644 --- a/hermes_cli/model_setup_flows_custom.py +++ b/hermes_cli/model_setup_flows_custom.py @@ -398,7 +398,7 @@ def _model_flow_named_custom(config, provider_info): model.pop("base_url", None) model.pop("api_key", None) else: - model["provider"] = "custom" + model["provider"] = provider_info.get("provider_slug") or "custom" model["base_url"] = _custom_provider_base_url_config_value(provider_info, base_url) if config_api_key: model["api_key"] = config_api_key @@ -431,6 +431,7 @@ def _model_flow_named_custom(config, provider_info): save_config(cfg) else: # Save model name to the custom_providers entry for next time - _save_custom_provider(base_url, config_api_key, model_name, api_mode=api_mode) + _save_custom_provider(base_url, config_api_key, model_name, api_mode=api_mode, + name=name, key_env=key_env, provider_slug=provider_info.get("provider_slug", "")) _say(f"\n✅ Model set to: {model_name}", f" Provider: {name} ({base_url})") diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 40ef34998c04f..3c6f98fc16f50 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1824,19 +1824,31 @@ def apply_model_selection(model_cfg: Any, result: ModelSwitchResult) -> dict: return model_cfg -def persist_model_selection(result: ModelSwitchResult, config_path: Any = None) -> None: +def persist_model_selection(result: ModelSwitchResult, config_path: Any = None, *, reasoning_effort: str = "") -> None: """Write a successful :func:`switch_model` result to ``config_path`` (default: ``HERMES_HOME/config.yaml`` — the context override or ``HERMES_HOME`` at call time). - Targeted key writes, not a whole-``model:`` rewrite: a block rewrite destroys sibling keys the - user set there (``model_slots``, ``model_fallback``, ...). ``should_clear_context_pin`` can do - cold-start disk I/O — async callers run this on a worker thread.""" + ONE targeted read-modify-write: only the selection's own dotted keys change (``model.*`` + plus ``agent.reasoning_effort`` when paired), so sibling keys, layout and sections written + by a concurrent ``--global`` command (/reasoning, /fast) survive instead of being reverted + or deleted by a stale whole-document snapshot. The document is re-read atomically under the + writer lock immediately before the one dump, so keys another writer added after this call's + own read are preserved. The model and its effort share the same atomic save, so a failed + write cannot leave them on different selections. Async callers run this on a worker.""" from pathlib import Path from hermes_cli.config import get_config_path, read_user_config_raw - from utils import atomic_roundtrip_yaml_update + from utils import atomic_roundtrip_yaml_update_multi path = Path(config_path) if config_path else get_config_path() - for key, value in model_selection_config_updates(result, read_user_config_raw(path).get("model")).items(): - atomic_roundtrip_yaml_update(path, f"model.{key}", value) + if reasoning_effort: + from hermes_constants import parse_reasoning_effort + if parse_reasoning_effort(reasoning_effort) is None: + raise ValueError("Invalid reasoning effort") + config = read_user_config_raw(path) + updates = {f"model.{key}": value + for key, value in model_selection_config_updates(result, config.get("model")).items()} + if reasoning_effort: + updates["agent.reasoning_effort"] = reasoning_effort + atomic_roundtrip_yaml_update_multi(path, updates) try: # owner-only: config files contain API keys os.chmod(path, 0o600) except (OSError, NotImplementedError): diff --git a/hermes_cli/model_switch_providers.py b/hermes_cli/model_switch_providers.py index 292e9c9baced2..06bb18a0eed79 100644 --- a/hermes_cli/model_switch_providers.py +++ b/hermes_cli/model_switch_providers.py @@ -563,7 +563,8 @@ def _entry_credentials(entry: dict, *key_env_keys: str) -> tuple[Any, str, str]: if source is not None: return source, key_env, source.cache_identity inline_api_key = str(entry.get("api_key", "") or "").strip() - return inline_api_key, key_env, inline_api_key or (f"env:{key_env}" if key_env else "") + from hermes_cli.custom_provider_identity import credential_identity + return inline_api_key, key_env, credential_identity(entry) def _discover_flag(entry: dict): @@ -576,7 +577,8 @@ def _discover_flag(entry: dict): def _display_prefix(name: str) -> str: """Text before the per-model separator Hermes's own writer uses ("—" / " - ").""" - return next((name.split(sep)[0].strip() for sep in ("—", " - ") if sep in name), name) + from hermes_cli.custom_provider_identity import display_prefix + return display_prefix(name) def _group_display_name(display_name: str) -> str: @@ -704,6 +706,7 @@ class _PickerBuild: builtin_endpoints: set = field(default_factory=set) # (display_name, base_url) pairs from section 3 so section 4 skips overlapping rows. section3_pairs: set = field(default_factory=set) + section3_groups: set = field(default_factory=set) @property def current_provider_norm(self) -> str: @@ -981,6 +984,8 @@ def _lap_user_provider_rows(b: _PickerBuild, user_providers: dict) -> None: if not isinstance(ep_cfg, dict) or not is_provider_enabled(ep_cfg) or ep_name.lower() in b.seen_slugs: continue display_name = coerce_provider_id(ep_cfg.get("name")) or ep_name + from hermes_cli.custom_provider_identity import custom_provider_group_key + b.section3_groups.add(custom_provider_group_key({**ep_cfg, "name": display_name})) api_url = _entry_base_url(ep_cfg, ("base_url", "api", "url")) inline_api_key, key_env, cred_identity = _entry_credentials(ep_cfg, "key_env", "api_key_env") headers = _extra_headers_from_config(ep_cfg) @@ -1063,8 +1068,9 @@ def _lap_custom_provider_rows(b: _PickerBuild, custom_providers: list) -> None: their own rows.""" from hermes_cli.model_switch import _extra_headers_from_config, _scoped_key_env from hermes_cli.config import coerce_provider_id + from hermes_cli.custom_provider_identity import custom_provider_group_key, iter_custom_provider_routes groups: dict[tuple, dict] = {} - for entry in custom_providers: + for route_slug, entry in iter_custom_provider_routes(custom_providers): if not isinstance(entry, dict): continue raw_name = coerce_provider_id(entry.get("name")) @@ -1078,13 +1084,17 @@ def _lap_custom_provider_rows(b: _PickerBuild, custom_providers: list) -> None: entry_extra_headers = _extra_headers_from_config(entry) prefix = _display_prefix(raw_name) provider_key = str(entry.get("provider_key") or "").strip() - group_key = (api_url, cred_identity, api_mode, tuple(sorted(entry_extra_headers.items())), prefix.lower()) - display_name = prefix or raw_name + group_key = custom_provider_group_key(entry) grp = groups.setdefault(group_key, { - "slug": custom_provider_slug(display_name, provider_key), "name": display_name, + "slug": route_slug, "name": prefix or raw_name, "full_name": raw_name, "members": 0, + "group_key": group_key, "api_url": api_url, "api_key": "", "credential_identity": cred_identity, "models": [], "has_explicit_models": False, "discover_models": True, "api_mode": api_mode, "extra_headers": entry_extra_headers, "aliases": set()}) + # The add flow's "Display name" prompt promises the given name shows in the menu: + # keep it verbatim for single-entry rows; only a merged multi-entry row (per-model + # "Name — model" entries on one credential) collapses to the shared prefix. + grp["members"] += 1 grp["api_key"] = grp["api_key"] or api_key # first member with a key wins grp["discover_models"] = grp["discover_models"] and discover # one opt-out pins the whole row grp["aliases"].update(custom_provider_aliases(raw_name, provider_key)) @@ -1097,20 +1107,13 @@ def _lap_custom_provider_rows(b: _PickerBuild, custom_providers: list) -> None: if b.current_base_url_norm and _norm_url(grp["api_url"]) == b.current_base_url_norm) for grp in groups.values(): api_url, api_key, slug = grp["api_url"], grp.get("api_key", ""), grp["slug"] - # Slug claimed by a built-in/overlay/providers: row -> skip (don't shadow). - if slug.lower() in b.seen_slugs and slug.lower() not in section4_slugs: + # The keyed and legacy views may overlap, but a shared name/URL alone + # cannot identify a credential group. + if grp["group_key"] in b.section3_groups: continue - # Two custom endpoints with the same cleaned name: suffix a counter so both stay visible. - if slug.lower() in section4_slugs: - base_slug, n = slug, 2 - while f"{base_slug}-{n}".lower() in b.seen_slugs: - n += 1 - slug = f"{base_slug}-{n}" - grp["slug"] = slug - grp_url_norm = _norm_url(api_url) - pair_key = (str(grp["name"]).strip().lower(), grp_url_norm) - if pair_key[0] and pair_key[1] and pair_key in b.section3_pairs: + if any(row["slug"].lower() == slug.lower() for row in b.results): continue + grp_url_norm = _norm_url(api_url) # A built-in row already represents this endpoint (e.g. "my-dashscope" vs the # alibaba-coding-plan row): keep the built-in, hide the shadow. if grp_url_norm and grp_url_norm in b.builtin_endpoints: @@ -1133,7 +1136,8 @@ def _lap_custom_provider_rows(b: _PickerBuild, custom_providers: list) -> None: credential_identity=grp["credential_identity"]) except Exception: pass - b.add_endpoint_row(slug, grp["name"], grp["api_url"], grp["models"], is_current, native_catalog_empty) + row_name = grp["full_name"] if grp["members"] == 1 else grp["name"] + b.add_endpoint_row(slug, row_name, grp["api_url"], grp["models"], is_current, native_catalog_empty) section4_slugs.add(slug.lower()) diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 6329cbc6f7fc7..a777869925cac 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -443,6 +443,13 @@ def resolve_custom_provider(name: str, custom_providers: Optional[List[Dict[str, if not requested or not custom_providers or not isinstance(custom_providers, list): return None first_valid: Optional[ProviderDef] = None + from hermes_cli.custom_provider_identity import match_custom_provider_route + matched = match_custom_provider_route(name, custom_providers) + if matched is not None: + resolved_slug, matched_entry = matched + display_name = (matched_entry.get("name") or "").strip() + api_url = (matched_entry.get("base_url", "") or matched_entry.get("url", "") or matched_entry.get("api", "")).strip() + return _user_pdef(resolved_slug, display_name, api_url, (matched_entry.get("key_env") or "").strip()) # If the stored provider is the bare string "custom" (corrupt state from a prior model-switch bug), fall # back to the first custom provider entry so existing configs self-heal. (GH #17478) for entry in custom_providers: diff --git a/hermes_cli/runtime_provider_custom.py b/hermes_cli/runtime_provider_custom.py index d0ee1a81eee25..f68d17c4ac57a 100644 --- a/hermes_cli/runtime_provider_custom.py +++ b/hermes_cli/runtime_provider_custom.py @@ -171,19 +171,21 @@ def _match_new_style_provider(requested_norm: str, providers: Dict[str, Any]) -> def _match_legacy_custom_provider(requested_norm: str, custom_providers) -> Optional[Dict[str, Any]]: """Scan the legacy ``custom_providers:`` list for ``requested_norm``.""" - for entry in custom_providers: - name, base_url = (entry.get("name"), entry.get("base_url")) if isinstance(entry, dict) else (None, None) - if not isinstance(name, str) or not isinstance(base_url, str): - continue + from hermes_cli.custom_provider_identity import match_custom_provider_route + matched = match_custom_provider_route(requested_norm, custom_providers) + if matched is not None: + resolved_slug, entry = matched + name, base_url = _clean(entry.get("name")), _clean(_entry_url(entry)) provider_key = _clean(entry.get("provider_key", "")) - if requested_norm not in custom_provider_aliases(name, provider_key): - continue result = {"name": name.strip(), "base_url": base_url.strip(), "api_key": _clean(entry.get("api_key", ""))} model_name = _clean(entry.get("model", "")) if model_name: result["model"] = model_name _lift_common_custom_fields(entry, result, provider_key=provider_key, key_env=_clean(entry.get("key_env", "")), api_mode=_rp()._parse_api_mode(entry.get("api_mode"))) + result["provider_slug"] = resolved_slug + if entry.get("key_cmd"): + result["key_cmd"] = entry["key_cmd"] return result return None @@ -195,6 +197,12 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An rp = _rp() config = rp.load_config() providers = config.get("providers") + custom_providers = rp.get_compatible_custom_providers(config) + if requested_norm.startswith("custom:"): + # Explicit allocated slugs beat a keyed entry's ambiguous display alias. + found = _match_legacy_custom_provider(requested_norm, custom_providers) + if found: + return found found = _match_new_style_provider(requested_norm, providers) if isinstance(providers, dict) else None if found: return found @@ -229,7 +237,7 @@ def codex_model_provider_id(requested_provider: str) -> Optional[str]: return None if not entry: return None - return custom_provider_slug(str(entry.get("name") or ""), str(entry.get("provider_key") or "")).split(":", 1)[1] or None + return (entry.get("provider_slug") or custom_provider_slug(str(entry.get("name") or ""), str(entry.get("provider_key") or ""))).split(":", 1)[1] or None # ── identity recovery (bare "custom" -> durable ``custom:``) ───────────────────────── @@ -301,6 +309,20 @@ def canonical_custom_identity(*, base_url: Optional[str] = None, config_provider endpoint first, then the ownership-checked managed server, then a configured model or provider. Every session persistence/restore path shares this lookup.""" rp = _rp() + # Preserve an explicit route before using a URL shared by several keys. + candidate = str(config_provider or "").strip() + if not candidate: + try: + candidate = str(rp._get_model_config().get("provider") or "").strip() + except Exception: + pass + if candidate and candidate.lower() not in {"custom", "auto", "openrouter"}: + try: + entry = rp._get_named_custom_provider(candidate) + except Exception: + entry = None + if entry and (not base_url or _normalize_base_url_for_match(entry.get("base_url")) == _normalize_base_url_for_match(base_url)): + return entry.get("provider_slug") or custom_provider_slug(str(entry.get("name") or ""), str(entry.get("provider_key") or "")) if base_url: identity = find_custom_provider_identity(base_url) if identity: @@ -550,7 +572,7 @@ def _resolve_named_custom_runtime(*, requested_provider: str, explicit_api_key: return None pool_result = rp._try_resolve_from_custom_pool( base_url, "custom", custom_provider.get("api_mode"), - provider_name=custom_provider.get("provider_key") or custom_provider.get("name"), + provider_name=custom_provider.get("provider_slug") or custom_provider.get("provider_key") or custom_provider.get("name"), ) if pool_result: # The pool doesn't know the custom_providers fields — propagate them here too. diff --git a/locales/af.yaml b/locales/af.yaml index bafe0133dd15b..46e0fa534e7b4 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — verander model" usage_switch_provider: "`/model --provider ` — verander verskaffer" usage_persist: "`/model --global` — stoor permanent" + picker: + title: "Model en redenering" + scope_session: "hierdie sessie" + scope_profile: "hierdie bot se verstek" + header: "Teiken: {profile} · {scope}\nHuidige model: {model}" + guard_confirm: "⚠️ {warning}\n\nTeiken: {model} ({provider})\nDeur te gaan word die model gewissel; jy kan ook teruggaan om die keuse te verander." + btn_continue: "Gaan voort" + btn_back: "Terug" + btn_back_provider: "Terug na verskaffer" + btn_apply: "Pas model en vlak toe" + btn_cancel: "Kanselleer" + menu_provider: "Kies verskaffer" + menu_model: "Kies model" + menu_effort: "Redenering-inspanning" + hint_pick_provider: "Kies eers 'n verskaffer, dan 'n model en inspanning." + hint_pick_model: "Verskaffer: {provider}\nKies model en vlak en tik dan Toepas." + hint_model_limit: "Hierdie kaart wys die eerste {count} modelle. Gebruik /model vir die res." + effort_keep: "Behou huidige instelling (nou: {current})" + effort_default: "verstek" + effort_none: "none · skakel redenering af" + result_title: "Wissel-resultaat" + cancelled_title: "Gekanselleer" + cancelled_body: "Model en vlak onveranderd." + update_failed: "Kaart kon nie opgedateer word nie — stuur /model weer." + no_reply: "Geen wissel-resultaat ontvang nie — stuur /model om die huidige instelling te sien." + switch_failed: "Modelwissel het nie voltooi nie — stuur /model, kyk na die instelling en probeer weer." + stale_reply: "Hierdie kaart se sessie of magtiging het verander — stuur /model weer." + wrong_chat: "Hierdie kaart hoort aan 'n ander gesprek — stuur /model weer." agents: header: "🤖 **Aktiewe Agente & Take**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\nHuidige modus: `{mode}`\\n\\nKies \\'n opsie:" + picker_title: "⚡ **Priority Processing**\n\nHuidige modus: `{mode}`\n\nKies 'n opsie:" choice_fast: "fast — Priority Processing aan" choice_normal: "normal — standaardverwerking" choice_auto: "auto — vinnig vir die eerste sekondes van elke beurt" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` word nie ondersteun nie. Gebruik `/reasoning --global` om die globale verstek te verander." reset_done: "🧠 ✓ Sessie-redenering-oorskryf verwyder; val terug op globale konfigurasie." unknown_arg: "⚠️ Onbekende argument: `{arg}`\n\n**Geldige vlakke:** none, minimal, low, medium, high, xhigh, max, ultra\n**Vertoon:** show, hide\n**Permanent:** voeg `--global` by om verby hierdie sessie te stoor" - picker_title: "🧠 **Redenering-instellings**\\n\\n**Inspanning:** `{level}`\\n**Bereik:** {scope}\\n**Vertoon:** {display}\\n\\nKies \\'n opsie:" + picker_title: "🧠 **Redenering-instellings**\n\n**Inspanning:** `{level}`\n**Bereik:** {scope}\n**Vertoon:** {display}\n\nKies 'n opsie:" choice_none: "none — deaktiveer redenering" choice_reset: "reset — vee sessie-oorskrywing uit" choice_show: "wys redenering in antwoorde" diff --git a/locales/ar.yaml b/locales/ar.yaml index 0c9d28fdb851b..769fd007a7950 100644 --- a/locales/ar.yaml +++ b/locales/ar.yaml @@ -68,6 +68,34 @@ gateway: usage_switch_model: "`/model ` — تبديل النموذج" usage_switch_provider: "`/model --provider ` — تبديل المزوّد" usage_persist: "`/model --global` — حفظ دائم" + picker: + title: "النموذج والتفكير" + scope_session: "هذه الجلسة" + scope_profile: "الإعداد الافتراضي لهذا البوت" + header: "الهدف: {profile} · {scope}\nالنموذج الحالي: {model}" + guard_confirm: "⚠️ {warning}\n\nالهدف: {model} ({provider})\nالمتابعة ستُجري التبديل؛ يمكنك أيضًا الرجوع لتغيير الاختيار." + btn_continue: "متابعة" + btn_back: "رجوع" + btn_back_provider: "العودة إلى المزوّد" + btn_apply: "تطبيق النموذج والجهد" + btn_cancel: "إلغاء" + menu_provider: "اختر المزوّد" + menu_model: "اختر النموذج" + menu_effort: "جهد التفكير" + hint_pick_provider: "اختر المزوّد أولًا، ثم النموذج والجهد." + hint_pick_model: "المزوّد: {provider}\nاختر النموذج والجهد ثم اضغط تطبيق." + hint_model_limit: "تعرض هذه البطاقة أول {count} نماذج. استخدم /model لبقيتها." + effort_keep: "الإبقاء على الإعداد الحالي (الآن: {current})" + effort_default: "افتراضي" + effort_none: "none · إيقاف التفكير" + result_title: "نتيجة التبديل" + cancelled_title: "أُلغي" + cancelled_body: "لم يتغير النموذج والجهد." + update_failed: "تعذّر تحديث البطاقة — أرسل /model مرة أخرى." + no_reply: "لم يُستلم أي نتيجة — أرسل /model للتحقق من الإعداد الحالي." + switch_failed: "لم يكتمل تبديل النموذج — أرسل /model وتحقق ثم أعد المحاولة." + stale_reply: "تغيّرت جلسة هذه البطاقة أو تخويلها — أرسل /model مرة أخرى." + wrong_chat: "هذه البطاقة تخص محادثة أخرى — أرسل /model مرة أخرى." agents: header: "🤖 **الوكلاء والمهام النشطة**" diff --git a/locales/de.yaml b/locales/de.yaml index 3ff233e70ebf4..8c754dd25d360 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — Modell wechseln" usage_switch_provider: "`/model --provider ` — Anbieter wechseln" usage_persist: "`/model --global` — dauerhaft speichern" + picker: + title: "Modell & Reasoning" + scope_session: "diese Sitzung" + scope_profile: "Standard dieses Bots" + header: "Ziel: {profile} · {scope}\nAktuelles Modell: {model}" + guard_confirm: "⚠️ {warning}\n\nZiel: {model} ({provider})\nWeiter führt den Wechsel aus; du kannst auch zurückgehen und die Auswahl ändern." + btn_continue: "Weiter" + btn_back: "Zurück" + btn_back_provider: "Zurück zu Anbieter" + btn_apply: "Modell & Stärke anwenden" + btn_cancel: "Abbrechen" + menu_provider: "Anbieter wählen" + menu_model: "Modell wählen" + menu_effort: "Reasoning-Stärke" + hint_pick_provider: "Zuerst einen Anbieter wählen, dann Modell und Reasoning-Stärke." + hint_pick_model: "Anbieter: {provider}\nModell und Stärke wählen, dann Anwenden tippen." + hint_model_limit: "Diese Karte zeigt die ersten {count} Modelle. Weitere über /model." + effort_keep: "Aktuelle Einstellung behalten (aktuell {current})" + effort_default: "Standard" + effort_none: "none · Reasoning deaktivieren" + result_title: "Ergebnis der Modellwahl" + cancelled_title: "Abgebrochen" + cancelled_body: "Modell und Stärke unverändert." + update_failed: "Karten-Update fehlgeschlagen — bitte /model erneut senden." + no_reply: "Kein Wechsel-Ergebnis erhalten — /model senden, um die aktuelle Einstellung zu prüfen." + switch_failed: "Modellwechsel nicht abgeschlossen — /model senden, prüfen und erneut versuchen." + stale_reply: "Sitzung oder Autorisierung dieser Karte hat sich geändert — bitte /model erneut senden." + wrong_chat: "Diese Karte gehört zu einem anderen Chat — bitte /model erneut senden." agents: header: "🤖 **Aktive Agenten & Aufgaben**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\nAktueller Modus: `{mode}`\\n\\nOption wählen:" + picker_title: "⚡ **Priority Processing**\n\nAktueller Modus: `{mode}`\n\nOption wählen:" choice_fast: "fast — Priority Processing an" choice_normal: "normal — Standardverarbeitung" choice_auto: "auto — schnell in den ersten Sekunden jedes Zugs" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` wird nicht unterstützt. Verwenden Sie `/reasoning --global`, um den globalen Standard zu ändern." reset_done: "🧠 ✓ Sitzungs-Reasoning-Override gelöscht; Rückfall auf globale Konfiguration." unknown_arg: "⚠️ Unbekanntes Argument: `{arg}`\n\n**Gültige Stärken:** none, minimal, low, medium, high, xhigh, max, ultra\n**Anzeige:** show, hide\n**Speichern:** `--global` hinzufügen, um über die Sitzung hinaus zu speichern" - picker_title: "🧠 **Reasoning-Einstellungen**\\n\\n**Stärke:** `{level}`\\n**Geltungsbereich:** {scope}\\n**Anzeige:** {display}\\n\\nOption wählen:" + picker_title: "🧠 **Reasoning-Einstellungen**\n\n**Stärke:** `{level}`\n**Geltungsbereich:** {scope}\n**Anzeige:** {display}\n\nOption wählen:" choice_none: "none — Reasoning deaktivieren" choice_reset: "reset — Sitzungs-Override löschen" choice_show: "Reasoning in Antworten anzeigen" diff --git a/locales/en.yaml b/locales/en.yaml index 28e4c1a38724e..bbf1f03aa2887 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -60,6 +60,34 @@ gateway: usage_switch_model: "`/model ` — switch model" usage_switch_provider: "`/model --provider ` — switch provider" usage_persist: "`/model --global` — persist" + picker: + title: "Model & Reasoning" + scope_session: "this session" + scope_profile: "this bot's default" + header: "Target: {profile} · {scope}\nCurrent model: {model}" + guard_confirm: "⚠️ {warning}\n\nTarget: {model} ({provider})\nContinue to switch now, or go back to change the selection." + btn_continue: "Continue" + btn_back: "Back" + btn_back_provider: "Back to Provider" + btn_apply: "Apply model & effort" + btn_cancel: "Cancel" + menu_provider: "Select Provider" + menu_model: "Select model" + menu_effort: "Reasoning effort" + hint_pick_provider: "Pick a provider first, then a model and reasoning effort." + hint_pick_model: "Provider: {provider}\nPick a model and effort, then tap Apply." + hint_model_limit: "This card shows the first {count} models. Use /model for the rest." + effort_keep: "Keep current setting (now: {current})" + effort_default: "default" + effort_none: "none · disable reasoning" + result_title: "Model setting result" + cancelled_title: "Cancelled" + cancelled_body: "Model and effort unchanged." + update_failed: "Card update failed — please send /model again." + no_reply: "No switch result received — send /model to check the current setting." + switch_failed: "Model switch didn't complete — send /model to check the current setting, then retry." + stale_reply: "This card's session or authorization changed — please send /model again." + wrong_chat: "This card belongs to another chat — please send /model again." agents: header: "🤖 **Active Agents & Tasks**" diff --git a/locales/es.yaml b/locales/es.yaml index 4cfd56417506c..f80ee11a1f9d7 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — cambiar modelo" usage_switch_provider: "`/model --provider ` — cambiar proveedor" usage_persist: "`/model --global` — guardar de forma permanente" + picker: + title: "Modelo y razonamiento" + scope_session: "esta sesión" + scope_profile: "valor predeterminado de este bot" + header: "Destino: {profile} · {scope}\nModelo actual: {model}" + guard_confirm: "⚠️ {warning}\n\nDestino: {model} ({provider})\nContinuar hará el cambio; también puedes volver y cambiar la selección." + btn_continue: "Continuar" + btn_back: "Volver" + btn_back_provider: "Volver al proveedor" + btn_apply: "Aplicar modelo y esfuerzo" + btn_cancel: "Cancelar" + menu_provider: "Elegir proveedor" + menu_model: "Elegir modelo" + menu_effort: "Esfuerzo de razonamiento" + hint_pick_provider: "Elige primero un proveedor y luego el modelo y el esfuerzo." + hint_pick_model: "Proveedor: {provider}\nElige modelo y esfuerzo y pulsa Aplicar." + hint_model_limit: "Esta tarjeta muestra los primeros {count} modelos. Usa /model para el resto." + effort_keep: "Mantener ajuste actual (ahora: {current})" + effort_default: "predeterminado" + effort_none: "none · desactivar razonamiento" + result_title: "Resultado del cambio" + cancelled_title: "Cancelado" + cancelled_body: "Modelo y esfuerzo sin cambios." + update_failed: "No se pudo actualizar la tarjeta — vuelve a enviar /model." + no_reply: "No se recibió resultado — envía /model para consultar el ajuste actual." + switch_failed: "El cambio de modelo no se completó — envía /model, consulta y reintenta." + stale_reply: "La sesión o autorización de esta tarjeta cambió — vuelve a enviar /model." + wrong_chat: "Esta tarjeta pertenece a otro chat — vuelve a enviar /model." agents: header: "🤖 **Agentes y tareas activos**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\nModo actual: `{mode}`\\n\\nElige una opción:" + picker_title: "⚡ **Priority Processing**\n\nModo actual: `{mode}`\n\nElige una opción:" choice_fast: "fast — Priority Processing activado" choice_normal: "normal — procesamiento estándar" choice_auto: "auto — rápido en los primeros segundos de cada turno" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` no es compatible. Usa `/reasoning --global` para cambiar el valor global por defecto." reset_done: "🧠 ✓ Anulación de razonamiento de la sesión borrada; volviendo a la configuración global." unknown_arg: "⚠️ Argumento desconocido: `{arg}`\n\n**Niveles válidos:** none, minimal, low, medium, high, xhigh, max, ultra\n**Visualización:** show, hide\n**Persistir:** añade `--global` para guardar más allá de esta sesión" - picker_title: "🧠 **Ajustes de razonamiento**\\n\\n**Esfuerzo:** `{level}`\\n**Alcance:** {scope}\\n**Visualización:** {display}\\n\\nElige una opción:" + picker_title: "🧠 **Ajustes de razonamiento**\n\n**Esfuerzo:** `{level}`\n**Alcance:** {scope}\n**Visualización:** {display}\n\nElige una opción:" choice_none: "none — desactivar razonamiento" choice_reset: "reset — borrar el ajuste de sesión" choice_show: "mostrar razonamiento en respuestas" diff --git a/locales/fr.yaml b/locales/fr.yaml index 30689691e73a9..d104d5d78cd3a 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — changer de modèle" usage_switch_provider: "`/model --provider ` — changer de fournisseur" usage_persist: "`/model --global` — conserver" + picker: + title: "Modèle et raisonnement" + scope_session: "cette session" + scope_profile: "valeur par défaut de ce bot" + header: "Cible : {profile} · {scope}\nModèle actuel : {model}" + guard_confirm: "⚠️ {warning}\n\nCible : {model} ({provider})\nContinuer effectuera le changement ; vous pouvez aussi revenir modifier la sélection." + btn_continue: "Continuer" + btn_back: "Retour" + btn_back_provider: "Retour au fournisseur" + btn_apply: "Appliquer modèle et effort" + btn_cancel: "Annuler" + menu_provider: "Choisir un fournisseur" + menu_model: "Choisir un modèle" + menu_effort: "Effort de raisonnement" + hint_pick_provider: "Choisissez d'abord un fournisseur, puis un modèle et un effort." + hint_pick_model: "Fournisseur : {provider}\nChoisissez modèle et effort, puis touchez Appliquer." + hint_model_limit: "Cette carte affiche les {count} premiers modèles. Utilisez /model pour les autres." + effort_keep: "Conserver le réglage actuel (actuel : {current})" + effort_default: "par défaut" + effort_none: "none · désactiver le raisonnement" + result_title: "Résultat du changement" + cancelled_title: "Annulé" + cancelled_body: "Modèle et effort inchangés." + update_failed: "Échec de la mise à jour de la carte — renvoyez /model." + no_reply: "Aucun résultat reçu — envoyez /model pour vérifier le réglage actuel." + switch_failed: "Le changement de modèle n'a pas abouti — envoyez /model, vérifiez puis réessayez." + stale_reply: "La session ou l'autorisation de cette carte a changé — renvoyez /model." + wrong_chat: "Cette carte appartient à une autre conversation — renvoyez /model." agents: header: "🤖 **Agents et tâches actifs**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\nMode actuel : `{mode}`\\n\\nChoisissez une option :" + picker_title: "⚡ **Priority Processing**\n\nMode actuel : `{mode}`\n\nChoisissez une option :" choice_fast: "fast — Priority Processing activé" choice_normal: "normal — traitement standard" choice_auto: "auto — rapide pendant les premières secondes de chaque tour" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` n'est pas pris en charge. Utilisez `/reasoning --global` pour modifier la valeur globale par défaut." reset_done: "🧠 ✓ Remplacement de raisonnement de la session effacé ; retour à la configuration globale." unknown_arg: "⚠️ Argument inconnu : `{arg}`\n\n**Niveaux valides :** none, minimal, low, medium, high, xhigh, max, ultra\n**Affichage :** show, hide\n**Persister :** ajoutez `--global` pour enregistrer au-delà de cette session" - picker_title: "🧠 **Paramètres de raisonnement**\\n\\n**Effort :** `{level}`\\n**Portée :** {scope}\\n**Affichage :** {display}\\n\\nChoisissez une option :" + picker_title: "🧠 **Paramètres de raisonnement**\n\n**Effort :** `{level}`\n**Portée :** {scope}\n**Affichage :** {display}\n\nChoisissez une option :" choice_none: "none — désactiver le raisonnement" choice_reset: "reset — effacer le réglage de session" choice_show: "afficher le raisonnement dans les réponses" diff --git a/locales/ga.yaml b/locales/ga.yaml index c02c9bc3c93da..f3f0c3d45b8ac 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -49,6 +49,34 @@ gateway: usage_switch_model: "`/model ` — athraigh an tsamhail" usage_switch_provider: "`/model --provider ` — athraigh an soláthraí" usage_persist: "`/model --global` — coinnigh" + picker: + title: "Samhail agus Réasúnaíocht" + scope_session: "an seisiún seo" + scope_profile: "réamhshocrú an bhot seo" + header: "Sprioc: {profile} · {scope}\nSamhail reatha: {model}" + guard_confirm: "⚠️ {warning}\n\nSprioc: {model} ({provider})\nLeanfaidh sé ar aghaidh leis an athrú; is féidir leat filleadh freisin chun an rogha a athrú." + btn_continue: "Lean ar aghaidh" + btn_back: "Ar ais" + btn_back_provider: "Ar ais chuig an soláthraí" + btn_apply: "Cuir samhail agus leibhéal i bhfeidhm" + btn_cancel: "Cealaigh" + menu_provider: "Roghnaigh soláthraí" + menu_model: "Roghnaigh samhail" + menu_effort: "Iarracht réasúnaíochta" + hint_pick_provider: "Roghnaigh soláthraí ar dtús, ansin samhail agus iarracht." + hint_pick_model: "Soláthraí: {provider}\nRoghnaigh samhail agus leibhéal, ansin tapáil Cuir i bhfeidhm." + hint_model_limit: "Taispeánann an cárta seo na chéad {count} samhail. Úsáid /model don chuid eile." + effort_keep: "Coinnigh an socrú reatha (anois: {current})" + effort_default: "réamhshocraithe" + effort_none: "none · díchumasaigh réasúnaíocht" + result_title: "Toradh an athraithe" + cancelled_title: "Cealaithe" + cancelled_body: "Samhail agus leibhéal gan athrú." + update_failed: "Theip ar nuashonrú an chárta — seol /model arís." + no_reply: "Ní bhfuarthas toradh — seol /model chun an socrú reatha a fheiceáil." + switch_failed: "Níor críochnaíodh an t-athrú samhla — seol /model, seiceáil agus bain triail eile as." + stale_reply: "Athraíodh seisiún nó údarú an chárta seo — seol /model arís." + wrong_chat: "Baineann an cárta seo le comhrá eile — seol /model arís." agents: header: "🤖 **Gníomhairí & Tascanna Gníomhacha**" @@ -141,7 +169,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\nMód reatha: `{mode}`\\n\\nRoghnaigh rogha:" + picker_title: "⚡ **Priority Processing**\n\nMód reatha: `{mode}`\n\nRoghnaigh rogha:" choice_fast: "fast — Priority Processing ar siúl" choice_normal: "normal — gnáthphróiseáil" choice_auto: "auto — tapa do na chéad soicindí de gach seal" @@ -222,7 +250,7 @@ gateway: reset_global_unsupported: "⚠️ Ní thacaítear le `/reasoning reset --global`. Úsáid `/reasoning --global` chun an réamhshocrú domhanda a athrú." reset_done: "🧠 ✓ Sárú réasúnaíochta seisiúin glanta; ag titim siar ar an gcumraíocht dhomhanda." unknown_arg: "⚠️ Argóint anaithnid: `{arg}`\n\n**Leibhéil bhailí:** none, minimal, low, medium, high, xhigh, max, ultra\n**Taispeáint:** show, hide\n**Coinnigh:** cuir `--global` leis chun sábháil thar an seisiún seo" - picker_title: "🧠 **Socruithe Réasúnaíochta**\\n\\n**Iarracht:** `{level}`\\n**Scóip:** {scope}\\n**Taispeáint:** {display}\\n\\nRoghnaigh rogha:" + picker_title: "🧠 **Socruithe Réasúnaíochta**\n\n**Iarracht:** `{level}`\n**Scóip:** {scope}\n**Taispeáint:** {display}\n\nRoghnaigh rogha:" choice_none: "none — díchumasaigh réasúnaíocht" choice_reset: "reset — glan sárú an tseisiúin" choice_show: "taispeáin réasúnaíocht sna freagraí" diff --git a/locales/hu.yaml b/locales/hu.yaml index 3a2146bd2e925..0e6f8ad9e4eff 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — modell váltása" usage_switch_provider: "`/model --provider ` — szolgáltató váltása" usage_persist: "`/model --global` — megőrzés" + picker: + title: "Modell és gondolkodás" + scope_session: "ez a munkamenet" + scope_profile: "a bot alapértelmezése" + header: "Cél: {profile} · {scope}\nJelenlegi modell: {model}" + guard_confirm: "⚠️ {warning}\n\nCél: {model} ({provider})\nA folytatás végrehajtja a váltást; vissza is léphetsz a kijelölés módosításához." + btn_continue: "Folytatás" + btn_back: "Vissza" + btn_back_provider: "Vissza a szolgáltatóhoz" + btn_apply: "Modell és szint alkalmazása" + btn_cancel: "Mégse" + menu_provider: "Szolgáltató kiválasztása" + menu_model: "Modell kiválasztása" + menu_effort: "Gondolkodási erőfeszítés" + hint_pick_provider: "Először válassz szolgáltatót, majd modellt és erőfeszítést." + hint_pick_model: "Szolgáltató: {provider}\nVálassz modellt és szintet, majd érintsd meg az Alkalmaz gombot." + hint_model_limit: "A kártya az első {count} modellt mutatja. A többit /model-lel érheted el." + effort_keep: "Jelenlegi beállítás megtartása (most: {current})" + effort_default: "alapértelmezett" + effort_none: "none · gondolkodás kikapcsolása" + result_title: "Váltás eredménye" + cancelled_title: "Megszakítva" + cancelled_body: "A modell és a szint változatlan." + update_failed: "A kártya frissítése nem sikerült — küldd újra a /model parancsot." + no_reply: "Nem érkezett eredmény — a /model paranccsal ellenőrizheted a jelenlegi beállítást." + switch_failed: "A modellváltás nem fejeződött be — küldd újra a /model parancsot, ellenőrizd, majd próbáld újra." + stale_reply: "A kártya munkamenete vagy engedélyezése megváltozott — küldd újra a /model parancsot." + wrong_chat: "Ez a kártya másik csevegéshez tartozik — küldd újra a /model parancsot." agents: header: "🤖 **Aktív ügynökök és feladatok**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\nJelenlegi mód: `{mode}`\\n\\nVálassz egy opciót:" + picker_title: "⚡ **Priority Processing**\n\nJelenlegi mód: `{mode}`\n\nVálassz egy opciót:" choice_fast: "fast — Priority Processing bekapcsolva" choice_normal: "normal — normál feldolgozás" choice_auto: "auto — gyors minden kör első másodperceiben" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ A `/reasoning reset --global` nem támogatott. Használd a `/reasoning --global` parancsot a globális alapérték módosításához." reset_done: "🧠 ✓ A munkamenet gondolkodási felülbírálása törölve; visszaállás a globális konfigurációra." unknown_arg: "⚠️ Ismeretlen argumentum: `{arg}`\n\n**Érvényes szintek:** none, minimal, low, medium, high, xhigh, max, ultra\n**Megjelenítés:** show, hide\n**Megőrzés:** add hozzá a `--global` opciót a munkameneten túli mentéshez" - picker_title: "🧠 **Gondolkodási beállítások**\\n\\n**Erőfeszítés:** `{level}`\\n**Hatókör:** {scope}\\n**Megjelenítés:** {display}\\n\\nVálassz egy opciót:" + picker_title: "🧠 **Gondolkodási beállítások**\n\n**Erőfeszítés:** `{level}`\n**Hatókör:** {scope}\n**Megjelenítés:** {display}\n\nVálassz egy opciót:" choice_none: "none — gondolkodás kikapcsolása" choice_reset: "reset — munkameneti felülbírálás törlése" choice_show: "gondolkodás megjelenítése a válaszokban" diff --git a/locales/it.yaml b/locales/it.yaml index bb6b7811e36aa..5b7c843b72824 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — cambia modello" usage_switch_provider: "`/model --provider ` — cambia provider" usage_persist: "`/model --global` — rendi permanente" + picker: + title: "Modello e reasoning" + scope_session: "questa sessione" + scope_profile: "predefinito di questo bot" + header: "Destinazione: {profile} · {scope}\nModello attuale: {model}" + guard_confirm: "⚠️ {warning}\n\nDestinazione: {model} ({provider})\nContinuando verrà effettuato il cambio; puoi anche tornare a modificare la selezione." + btn_continue: "Continua" + btn_back: "Indietro" + btn_back_provider: "Torna al provider" + btn_apply: "Applica modello e sforzo" + btn_cancel: "Annulla" + menu_provider: "Scegli provider" + menu_model: "Scegli modello" + menu_effort: "Sforzo di reasoning" + hint_pick_provider: "Scegli prima un provider, poi modello e sforzo." + hint_pick_model: "Provider: {provider}\nScegli modello e sforzo, poi tocca Applica." + hint_model_limit: "Questa card mostra i primi {count} modelli. Usa /model per gli altri." + effort_keep: "Mantieni impostazione attuale (ora: {current})" + effort_default: "predefinito" + effort_none: "none · disattiva reasoning" + result_title: "Esito della selezione" + cancelled_title: "Annullato" + cancelled_body: "Modello e sforzo invariati." + update_failed: "Aggiornamento della card non riuscito — invia di nuovo /model." + no_reply: "Nessun esito ricevuto — invia /model per verificare l'impostazione attuale." + switch_failed: "Cambio modello non completato — invia /model, verifica e riprova." + stale_reply: "La sessione o l'autorizzazione di questa card è cambiata — invia di nuovo /model." + wrong_chat: "Questa card appartiene a un'altra chat — invia di nuovo /model." agents: header: "🤖 **Agenti e attività attivi**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\nModalità attuale: `{mode}`\\n\\nScegli un\\'opzione:" + picker_title: "⚡ **Priority Processing**\n\nModalità attuale: `{mode}`\n\nScegli un'opzione:" choice_fast: "fast — Priority Processing attivo" choice_normal: "normal — elaborazione standard" choice_auto: "auto — veloce nei primi secondi di ogni turno" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` non è supportato. Usa `/reasoning --global` per cambiare il valore predefinito globale." reset_done: "🧠 ✓ Override di reasoning della sessione cancellato; ripristino della configurazione globale." unknown_arg: "⚠️ Argomento sconosciuto: `{arg}`\n\n**Livelli validi:** none, minimal, low, medium, high, xhigh, max, ultra\n**Visualizzazione:** show, hide\n**Persistenza:** aggiungi `--global` per salvare oltre questa sessione" - picker_title: "🧠 **Impostazioni di reasoning**\\n\\n**Sforzo:** `{level}`\\n**Ambito:** {scope}\\n**Visualizzazione:** {display}\\n\\nScegli un\\'opzione:" + picker_title: "🧠 **Impostazioni di reasoning**\n\n**Sforzo:** `{level}`\n**Ambito:** {scope}\n**Visualizzazione:** {display}\n\nScegli un'opzione:" choice_none: "none — disattiva il reasoning" choice_reset: "reset — cancella l'override di sessione" choice_show: "mostra il reasoning nelle risposte" diff --git a/locales/ja.yaml b/locales/ja.yaml index 6147e7803c0eb..21bf1d95f1f33 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — モデルを切り替え" usage_switch_provider: "`/model --provider ` — プロバイダーを切り替え" usage_persist: "`/model --global` — 永続化" + picker: + title: "モデルと推論強度" + scope_session: "このセッション" + scope_profile: "このボットのデフォルト" + header: "対象:{profile} · {scope}\n現在のモデル:{model}" + guard_confirm: "⚠️ {warning}\n\n対象:{model}({provider})\n続行すると切り替えます。戻って選択を変更することもできます。" + btn_continue: "切り替えを続行" + btn_back: "戻る" + btn_back_provider: "プロバイダーに戻る" + btn_apply: "モデルと強度を適用" + btn_cancel: "キャンセル" + menu_provider: "プロバイダーを選択" + menu_model: "モデルを選択" + menu_effort: "推論強度" + hint_pick_provider: "まずプロバイダーを選択し、次にモデルと推論強度を選択してください。" + hint_pick_model: "プロバイダー:{provider}\nモデルと強度を選び、「適用」をタップしてください。" + hint_model_limit: "このカードには最初の {count} 個のモデルのみ表示されます。その他は /model で指定できます。" + effort_keep: "現在の設定を維持(現在 {current})" + effort_default: "デフォルト" + effort_none: "none · 推論を無効化" + result_title: "モデル設定の結果" + cancelled_title: "キャンセルしました" + cancelled_body: "モデルと強度は変更されていません。" + update_failed: "カードの更新に失敗しました。/model を再送信してください。" + no_reply: "切り替え結果を受信できませんでした。/model で現在の設定を確認してください。" + switch_failed: "モデルの切り替えが完了しませんでした。/model で確認して再試行してください。" + stale_reply: "このカードのセッションまたは認証が変更されました。/model を再送信してください。" + wrong_chat: "このカードは別のチャットのものです。/model を再送信してください。" agents: header: "🤖 **アクティブなエージェントとタスク**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\n現在のモード: `{mode}`\\n\\nオプションを選択:" + picker_title: "⚡ **Priority Processing**\n\n現在のモード: `{mode}`\n\nオプションを選択:" choice_fast: "fast — Priority Processing オン" choice_normal: "normal — 標準処理" choice_auto: "auto — 各ターンの最初の数秒間だけ高速" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` はサポートされていません。グローバルのデフォルトを変更するには `/reasoning --global` を使用してください。" reset_done: "🧠 ✓ セッションの推論オーバーライドをクリアしました。グローバル設定にフォールバックします。" unknown_arg: "⚠️ 不明な引数: `{arg}`\n\n**有効なレベル:** none, minimal, low, medium, high, xhigh, max, ultra\n**表示:** show, hide\n**永続化:** セッションを越えて保存するには `--global` を追加" - picker_title: "🧠 **推論設定**\\n\\n**強度:** `{level}`\\n**スコープ:** {scope}\\n**表示:** {display}\\n\\nオプションを選択:" + picker_title: "🧠 **推論設定**\n\n**強度:** `{level}`\n**スコープ:** {scope}\n**表示:** {display}\n\nオプションを選択:" choice_none: "none — 推論を無効化" choice_reset: "reset — セッション上書きをクリア" choice_show: "返信に推論を表示" diff --git a/locales/ko.yaml b/locales/ko.yaml index 394cc80ec722a..9bc68bee3930e 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — 모델 전환" usage_switch_provider: "`/model --provider ` — 제공자 전환" usage_persist: "`/model --global` — 영구 저장" + picker: + title: "모델 및 추론" + scope_session: "이 세션" + scope_profile: "이 봇의 기본값" + header: "대상: {profile} · {scope}\n현재 모델: {model}" + guard_confirm: "⚠️ {warning}\n\n대상: {model} ({provider})\n계속하면 전환됩니다. 뒤로 돌아가 선택을 변경할 수도 있습니다." + btn_continue: "계속 전환" + btn_back: "뒤로" + btn_back_provider: "제공자로 돌아가기" + btn_apply: "모델과 노력 적용" + btn_cancel: "취소" + menu_provider: "제공자 선택" + menu_model: "모델 선택" + menu_effort: "추론 노력" + hint_pick_provider: "먼저 제공자를 선택한 뒤 모델과 추론 노력을 선택하세요." + hint_pick_model: "제공자: {provider}\n모델과 노력을 선택한 뒤 적용을 누르세요." + hint_model_limit: "이 카드에는 처음 {count}개 모델만 표시됩니다. 나머지는 /model 로 지정하세요." + effort_keep: "현재 설정 유지 (현재 {current})" + effort_default: "기본값" + effort_none: "none · 추론 끄기" + result_title: "전환 결과" + cancelled_title: "취소됨" + cancelled_body: "모델과 노력이 변경되지 않았습니다." + update_failed: "카드 업데이트에 실패했습니다. /model 을 다시 보내세요." + no_reply: "전환 결과를 받지 못했습니다. /model 로 현재 설정을 확인하세요." + switch_failed: "모델 전환이 완료되지 않았습니다. /model 로 확인 후 다시 시도하세요." + stale_reply: "이 카드의 세션 또는 권한이 변경되었습니다. /model 을 다시 보내세요." + wrong_chat: "이 카드는 다른 채팅의 것입니다. /model 을 다시 보내세요." agents: header: "🤖 **활성 에이전트 및 작업**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\n현재 모드: `{mode}`\\n\\n옵션을 선택하세요:" + picker_title: "⚡ **Priority Processing**\n\n현재 모드: `{mode}`\n\n옵션을 선택하세요:" choice_fast: "fast — Priority Processing 켜기" choice_normal: "normal — 표준 처리" choice_auto: "auto — 매 턴의 처음 몇 초 동안 빠름" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global`은 지원되지 않습니다. 전역 기본값을 변경하려면 `/reasoning --global`을 사용하세요." reset_done: "🧠 ✓ 세션 추론 재정의가 해제되었습니다. 전역 설정으로 돌아갑니다." unknown_arg: "⚠️ 알 수 없는 인수: `{arg}`\n\n**유효한 수준:** none, minimal, low, medium, high, xhigh, max, ultra\n**표시:** show, hide\n**영구화:** 이 세션을 넘어 저장하려면 `--global`을 추가하세요" - picker_title: "🧠 **추론 설정**\\n\\n**노력:** `{level}`\\n**범위:** {scope}\\n**표시:** {display}\\n\\n옵션을 선택하세요:" + picker_title: "🧠 **추론 설정**\n\n**노력:** `{level}`\n**범위:** {scope}\n**표시:** {display}\n\n옵션을 선택하세요:" choice_none: "none — 추론 비활성화" choice_reset: "reset — 세션 재정의 초기화" choice_show: "응답에 추론 표시" diff --git a/locales/pt.yaml b/locales/pt.yaml index d20c2b58fbc0a..95f9511b7d82f 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — mudar de modelo" usage_switch_provider: "`/model --provider ` — mudar de fornecedor" usage_persist: "`/model --global` — guardar permanentemente" + picker: + title: "Modelo e raciocínio" + scope_session: "esta sessão" + scope_profile: "padrão deste bot" + header: "Destino: {profile} · {scope}\nModelo atual: {model}" + guard_confirm: "⚠️ {warning}\n\nDestino: {model} ({provider})\nContinuar fará a troca; você também pode voltar e alterar a seleção." + btn_continue: "Continuar" + btn_back: "Voltar" + btn_back_provider: "Voltar ao fornecedor" + btn_apply: "Aplicar modelo e esforço" + btn_cancel: "Cancelar" + menu_provider: "Escolher fornecedor" + menu_model: "Escolher modelo" + menu_effort: "Esforço de raciocínio" + hint_pick_provider: "Escolha primeiro um fornecedor, depois o modelo e o esforço." + hint_pick_model: "Fornecedor: {provider}\nEscolha modelo e esforço e toque em Aplicar." + hint_model_limit: "Este cartão mostra os primeiros {count} modelos. Use /model para os demais." + effort_keep: "Manter definição atual (agora: {current})" + effort_default: "padrão" + effort_none: "none · desativar raciocínio" + result_title: "Resultado da troca" + cancelled_title: "Cancelado" + cancelled_body: "Modelo e esforço inalterados." + update_failed: "Falha ao atualizar o cartão — envie /model novamente." + no_reply: "Nenhum resultado recebido — envie /model para verificar a definição atual." + switch_failed: "A troca de modelo não foi concluída — envie /model, verifique e tente de novo." + stale_reply: "A sessão ou autorização deste cartão mudou — envie /model novamente." + wrong_chat: "Este cartão pertence a outra conversa — envie /model novamente." agents: header: "🤖 **Agentes e tarefas ativos**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\nModo atual: `{mode}`\\n\\nEscolha uma opção:" + picker_title: "⚡ **Priority Processing**\n\nModo atual: `{mode}`\n\nEscolha uma opção:" choice_fast: "fast — Priority Processing ativado" choice_normal: "normal — processamento padrão" choice_auto: "auto — rápido nos primeiros segundos de cada turno" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` não é suportado. Usa `/reasoning --global` para alterar o predefinido global." reset_done: "🧠 ✓ Substituição de raciocínio da sessão removida; a regressar à configuração global." unknown_arg: "⚠️ Argumento desconhecido: `{arg}`\n\n**Níveis válidos:** none, minimal, low, medium, high, xhigh, max, ultra\n**Visualização:** show, hide\n**Persistir:** adiciona `--global` para guardar para além desta sessão" - picker_title: "🧠 **Definições de raciocínio**\\n\\n**Esforço:** `{level}`\\n**Âmbito:** {scope}\\n**Visualização:** {display}\\n\\nEscolha uma opção:" + picker_title: "🧠 **Definições de raciocínio**\n\n**Esforço:** `{level}`\n**Âmbito:** {scope}\n**Visualização:** {display}\n\nEscolha uma opção:" choice_none: "none — desativar raciocínio" choice_reset: "reset — limpar o override de sessão" choice_show: "mostrar raciocínio nas respostas" diff --git a/locales/ru.yaml b/locales/ru.yaml index 4fb355d882c7c..ed20ebedd8f24 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — сменить модель" usage_switch_provider: "`/model --provider ` — сменить провайдера" usage_persist: "`/model --global` — сохранить навсегда" + picker: + title: "Модель и рассуждения" + scope_session: "этот сеанс" + scope_profile: "по умолчанию для этого бота" + header: "Цель: {profile} · {scope}\nТекущая модель: {model}" + guard_confirm: "⚠️ {warning}\n\nЦель: {model} ({provider})\nПродолжение выполнит переключение; можно вернуться и изменить выбор." + btn_continue: "Продолжить" + btn_back: "Назад" + btn_back_provider: "Назад к провайдеру" + btn_apply: "Применить модель и уровень" + btn_cancel: "Отмена" + menu_provider: "Выбрать провайдера" + menu_model: "Выбрать модель" + menu_effort: "Усилия рассуждений" + hint_pick_provider: "Сначала выберите провайдера, затем модель и усилия." + hint_pick_model: "Провайдер: {provider}\nВыберите модель и уровень, затем нажмите «Применить»." + hint_model_limit: "На карточке показаны первые {count} моделей. Остальные — через /model." + effort_keep: "Оставить текущую настройку (сейчас: {current})" + effort_default: "по умолчанию" + effort_none: "none · отключить рассуждения" + result_title: "Результат переключения" + cancelled_title: "Отменено" + cancelled_body: "Модель и уровень не изменены." + update_failed: "Не удалось обновить карточку — отправьте /model ещё раз." + no_reply: "Результат не получен — отправьте /model, чтобы проверить текущую настройку." + switch_failed: "Переключение модели не завершено — отправьте /model, проверьте и повторите." + stale_reply: "Сеанс или авторизация этой карточки изменились — отправьте /model ещё раз." + wrong_chat: "Эта карточка из другого чата — отправьте /model ещё раз." agents: header: "🤖 **Активные агенты и задачи**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\nТекущий режим: `{mode}`\\n\\nВыберите вариант:" + picker_title: "⚡ **Priority Processing**\n\nТекущий режим: `{mode}`\n\nВыберите вариант:" choice_fast: "fast — Priority Processing включён" choice_normal: "normal — стандартная обработка" choice_auto: "auto — быстро в первые секунды каждого хода" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` не поддерживается. Используйте `/reasoning --global`, чтобы изменить глобальное значение по умолчанию." reset_done: "🧠 ✓ Переопределение рассуждений для сеанса сброшено; возврат к глобальной конфигурации." unknown_arg: "⚠️ Неизвестный аргумент: `{arg}`\n\n**Допустимые уровни:** none, minimal, low, medium, high, xhigh, max, ultra\n**Отображение:** show, hide\n**Сохранение:** добавьте `--global`, чтобы сохранить за пределами этого сеанса" - picker_title: "🧠 **Настройки рассуждений**\\n\\n**Усилия:** `{level}`\\n**Область:** {scope}\\n**Отображение:** {display}\\n\\nВыберите вариант:" + picker_title: "🧠 **Настройки рассуждений**\n\n**Усилия:** `{level}`\n**Область:** {scope}\n**Отображение:** {display}\n\nВыберите вариант:" choice_none: "none — отключить рассуждения" choice_reset: "reset — сбросить переопределение сессии" choice_show: "показывать рассуждения в ответах" diff --git a/locales/tr.yaml b/locales/tr.yaml index 9a3b9b2e0a2b9..5609633d35554 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — modeli değiştir" usage_switch_provider: "`/model --provider ` — sağlayıcıyı değiştir" usage_persist: "`/model --global` — kalıcı kaydet" + picker: + title: "Model ve Akıl Yürütme" + scope_session: "bu oturum" + scope_profile: "bu botun varsayılanı" + header: "Hedef: {profile} · {scope}\nMevcut model: {model}" + guard_confirm: "⚠️ {warning}\n\nHedef: {model} ({provider})\nDevam ederseniz geçiş yapılır; geri dönüp seçimi değiştirebilirsiniz." + btn_continue: "Devam et" + btn_back: "Geri" + btn_back_provider: "Sağlayıcıya dön" + btn_apply: "Model ve gücü uygula" + btn_cancel: "İptal" + menu_provider: "Sağlayıcı seç" + menu_model: "Model seç" + menu_effort: "Akıl yürütme gücü" + hint_pick_provider: "Önce bir sağlayıcı, ardından model ve güç seçin." + hint_pick_model: "Sağlayıcı: {provider}\nModel ve gücü seçip Uygula'ya dokunun." + hint_model_limit: "Bu kart ilk {count} modeli gösterir. Diğerleri için /model kullanın." + effort_keep: "Mevcut ayarı koru (şu an: {current})" + effort_default: "varsayılan" + effort_none: "none · akıl yürütmeyi kapat" + result_title: "Geçiş sonucu" + cancelled_title: "İptal edildi" + cancelled_body: "Model ve güç değişmedi." + update_failed: "Kart güncellenemedi — lütfen /model komutunu yeniden gönderin." + no_reply: "Geçiş sonucu alınamadı — mevcut ayarı görmek için /model gönderin." + switch_failed: "Model geçişi tamamlanmadı — /model gönderip kontrol edin ve yeniden deneyin." + stale_reply: "Bu kartın oturumu veya yetkisi değişti — lütfen /model komutunu yeniden gönderin." + wrong_chat: "Bu kart başka bir sohbete ait — lütfen /model komutunu yeniden gönderin." agents: header: "🤖 **Aktif Ajanlar ve Görevler**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\nMevcut mod: `{mode}`\\n\\nBir seçenek seçin:" + picker_title: "⚡ **Priority Processing**\n\nMevcut mod: `{mode}`\n\nBir seçenek seçin:" choice_fast: "fast — Priority Processing açık" choice_normal: "normal — standart işleme" choice_auto: "auto — her turun ilk saniyelerinde hızlı" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` desteklenmiyor. Genel varsayılanı değiştirmek için `/reasoning --global` kullanın." reset_done: "🧠 ✓ Oturumun akıl yürütme geçersiz kılması temizlendi; genel yapılandırmaya geri dönülüyor." unknown_arg: "⚠️ Bilinmeyen argüman: `{arg}`\n\n**Geçerli seviyeler:** none, minimal, low, medium, high, xhigh, max, ultra\n**Görüntüleme:** show, hide\n**Kalıcı:** bu oturumun ötesinde kaydetmek için `--global` ekleyin" - picker_title: "🧠 **Akıl Yürütme Ayarları**\\n\\n**Güç:** `{level}`\\n**Kapsam:** {scope}\\n**Görüntüleme:** {display}\\n\\nBir seçenek seçin:" + picker_title: "🧠 **Akıl Yürütme Ayarları**\n\n**Güç:** `{level}`\n**Kapsam:** {scope}\n**Görüntüleme:** {display}\n\nBir seçenek seçin:" choice_none: "none — akıl yürütmeyi kapat" choice_reset: "reset — oturum geçersiz kılmasını temizle" choice_show: "yanıtlarda akıl yürütmeyi göster" diff --git a/locales/uk.yaml b/locales/uk.yaml index 54176316e3155..df927512c0194 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — змінити модель" usage_switch_provider: "`/model --provider ` — змінити провайдера" usage_persist: "`/model --global` — зберегти назавжди" + picker: + title: "Модель і мислення" + scope_session: "цей сеанс" + scope_profile: "типове для цього бота" + header: "Ціль: {profile} · {scope}\nПоточна модель: {model}" + guard_confirm: "⚠️ {warning}\n\nЦіль: {model} ({provider})\nПродовження виконає перемикання; можна повернутися й змінити вибір." + btn_continue: "Продовжити" + btn_back: "Назад" + btn_back_provider: "Назад до провайдера" + btn_apply: "Застосувати модель і рівень" + btn_cancel: "Скасувати" + menu_provider: "Обрати провайдера" + menu_model: "Обрати модель" + menu_effort: "Зусилля мислення" + hint_pick_provider: "Спершу оберіть провайдера, потім модель і зусилля." + hint_pick_model: "Провайдер: {provider}\nОберіть модель і рівень, потім натисніть «Застосувати»." + hint_model_limit: "На картці показано перші {count} моделей. Решта — через /model." + effort_keep: "Залишити поточне налаштування (зараз: {current})" + effort_default: "типово" + effort_none: "none · вимкнути мислення" + result_title: "Результат перемикання" + cancelled_title: "Скасовано" + cancelled_body: "Модель і рівень не змінено." + update_failed: "Не вдалося оновити картку — надішліть /model ще раз." + no_reply: "Результат не отримано — надішліть /model, щоб переглянути поточне налаштування." + switch_failed: "Перемикання моделі не завершено — надішліть /model, перевірте та повторіть." + stale_reply: "Сеанс або авторизацію цієї картки змінено — надішліть /model ще раз." + wrong_chat: "Ця картка з іншого чату — надішліть /model ще раз." agents: header: "🤖 **Активні агенти та завдання**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\nПоточний режим: `{mode}`\\n\\nОберіть варіант:" + picker_title: "⚡ **Priority Processing**\n\nПоточний режим: `{mode}`\n\nОберіть варіант:" choice_fast: "fast — Priority Processing увімкнено" choice_normal: "normal — стандартна обробка" choice_auto: "auto — швидко в перші секунди кожного ходу" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` не підтримується. Використовуйте `/reasoning --global`, щоб змінити глобальне значення за замовчуванням." reset_done: "🧠 ✓ Перевизначення мислення для сеансу скинуто; повернення до глобальної конфігурації." unknown_arg: "⚠️ Невідомий аргумент: `{arg}`\n\n**Дійсні рівні:** none, minimal, low, medium, high, xhigh, max, ultra\n**Показ:** show, hide\n**Зберегти:** додайте `--global`, щоб зберегти поза цим сеансом" - picker_title: "🧠 **Налаштування мислення**\\n\\n**Зусилля:** `{level}`\\n**Область:** {scope}\\n**Показ:** {display}\\n\\nОберіть варіант:" + picker_title: "🧠 **Налаштування мислення**\n\n**Зусилля:** `{level}`\n**Область:** {scope}\n**Показ:** {display}\n\nОберіть варіант:" choice_none: "none — вимкнути мислення" choice_reset: "reset — скинути перевизначення сесії" choice_show: "показувати мислення у відповідях" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 9b7b11637372b..91b241b508d58 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — 切換模型" usage_switch_provider: "`/model --provider ` — 切換提供方" usage_persist: "`/model --global` — 永久儲存" + picker: + title: "模型與推理強度" + scope_session: "此工作階段" + scope_profile: "此機器人的預設設定" + header: "目標:{profile} · {scope}\n目前模型:{model}" + guard_confirm: "⚠️ {warning}\n\n目標:{model}({provider})\n確認後將繼續切換;也可返回修改選擇。" + btn_continue: "繼續切換" + btn_back: "返回" + btn_back_provider: "返回 Provider" + btn_apply: "套用模型與檔位" + btn_cancel: "取消" + menu_provider: "選擇 Provider" + menu_model: "選擇模型" + menu_effort: "推理強度" + hint_pick_provider: "先選擇 Provider,再選擇模型和推理強度。" + hint_pick_model: "Provider:{provider}\n選擇模型和檔位後,點擊套用。" + hint_model_limit: "本卡片顯示前 {count} 個模型。其他模型可用 /model 指定。" + effort_keep: "保持現有設定(目前 {current})" + effort_default: "預設" + effort_none: "none · 關閉推理" + result_title: "模型設定結果" + cancelled_title: "已取消" + cancelled_body: "模型與檔位未變更。" + update_failed: "卡片更新失敗,請重新傳送 /model。" + no_reply: "未收到切換結果,請傳送 /model 查詢目前設定。" + switch_failed: "模型切換未完成,請傳送 /model 查詢目前設定後重試。" + stale_reply: "此卡片的工作階段或授權已變更,請重新傳送 /model。" + wrong_chat: "此卡片不屬於目前聊天,請重新傳送 /model。" agents: header: "🤖 **作用中的代理與任務**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **Priority Processing**\\n\\n目前模式:`{mode}`\\n\\n請選擇:" + picker_title: "⚡ **Priority Processing**\n\n目前模式:`{mode}`\n\n請選擇:" choice_fast: "fast — 開啟 Priority Processing" choice_normal: "normal — 標準處理" choice_auto: "auto — 每輪的前幾秒快速" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ 不支援 `/reasoning reset --global`。請使用 `/reasoning --global` 變更全域預設值。" reset_done: "🧠 ✓ 已清除本工作階段的推理覆寫;回退至全域設定。" unknown_arg: "⚠️ 未知參數:`{arg}`\n\n**有效級別:** none, minimal, low, medium, high, xhigh, max, ultra\n**顯示:** show, hide\n**持久化:** 加上 `--global` 可跨工作階段儲存" - picker_title: "🧠 **推理設定**\\n\\n**強度:** `{level}`\\n**範圍:** {scope}\\n**顯示:** {display}\\n\\n請選擇:" + picker_title: "🧠 **推理設定**\n\n**強度:** `{level}`\n**範圍:** {scope}\n**顯示:** {display}\n\n請選擇:" choice_none: "none — 停用推理" choice_reset: "reset — 清除工作階段覆寫" choice_show: "在回覆中顯示推理" diff --git a/locales/zh.yaml b/locales/zh.yaml index f6b53587a18e8..cbea76802b27f 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -45,6 +45,34 @@ gateway: usage_switch_model: "`/model ` — 切换模型" usage_switch_provider: "`/model --provider ` — 切换提供方" usage_persist: "`/model --global` — 永久保存" + picker: + title: "模型与推理强度" + scope_session: "此会话" + scope_profile: "此机器人的默认设置" + header: "目标:{profile} · {scope}\n当前模型:{model}" + guard_confirm: "⚠️ {warning}\n\n目标:{model}({provider})\n确认后将继续切换;也可返回修改选择。" + btn_continue: "继续切换" + btn_back: "返回" + btn_back_provider: "返回 Provider" + btn_apply: "应用模型与档位" + btn_cancel: "取消" + menu_provider: "选择 Provider" + menu_model: "选择模型" + menu_effort: "推理强度" + hint_pick_provider: "先选择 Provider,再选择模型和推理强度。" + hint_pick_model: "Provider:{provider}\n选择模型和档位后,点击应用。" + hint_model_limit: "本卡片展示前 {count} 个模型。其他模型可用 /model 指定。" + effort_keep: "保持现有设置(当前 {current})" + effort_default: "默认" + effort_none: "none · 关闭推理" + result_title: "模型设置结果" + cancelled_title: "已取消" + cancelled_body: "模型与档位未更改。" + update_failed: "卡片更新失败,请重新发送 /model。" + no_reply: "未收到切换结果,请发送 /model 查询当前设置。" + switch_failed: "模型切换未完成,请发送 /model 查询当前设置后重试。" + stale_reply: "此卡片的会话或授权已变化,请重新发送 /model。" + wrong_chat: "此卡片不属于当前聊天,请重新发送 /model。" agents: header: "🤖 **活跃代理与任务**" @@ -137,7 +165,7 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" - picker_title: "⚡ **优先处理**\\n\\n当前模式:`{mode}`\\n\\n请选择:" + picker_title: "⚡ **优先处理**\n\n当前模式:`{mode}`\n\n请选择:" choice_fast: "fast — 开启优先处理" choice_normal: "normal — 标准处理" choice_auto: "auto — 每轮的前几秒快速" @@ -218,7 +246,7 @@ gateway: reset_global_unsupported: "⚠️ 不支持 `/reasoning reset --global`。请使用 `/reasoning --global` 修改全局默认值。" reset_done: "🧠 ✓ 已清除本会话的推理覆盖;回退到全局配置。" unknown_arg: "⚠️ 未知参数:`{arg}`\n\n**有效级别:** none, minimal, low, medium, high, xhigh, max, ultra\n**显示:** show, hide\n**持久化:** 添加 `--global` 以跨会话保存" - picker_title: "🧠 **推理设置**\\n\\n**强度:** `{level}`\\n**作用域:** {scope}\\n**显示:** {display}\\n\\n请选择:" + picker_title: "🧠 **推理设置**\n\n**强度:** `{level}`\n**作用域:** {scope}\n**显示:** {display}\n\n请选择:" choice_none: "none — 关闭推理" choice_reset: "reset — 清除会话覆盖" choice_show: "在回复中显示推理" diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 4895aac996642..2ad5c0985b30e 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -1280,7 +1280,10 @@ def _sdk_build(request_cls: Any, **fields: Any) -> Any: return builder.build() -class FeishuAdapter(BasePlatformAdapter): +from plugins.platforms.feishu.model_picker import FeishuModelPickerMixin + + +class FeishuAdapter(FeishuModelPickerMixin, BasePlatformAdapter): """Feishu/Lark bot adapter.""" # Answers /p//... on the default listener for a served secondary (shared_ingress). serves_profile_prefix: bool = True @@ -1340,6 +1343,7 @@ def __init__(self, config: PlatformConfig): self._approval_counter = itertools.count(1) self._update_prompt_state: Dict[int, Dict[str, str]] = {} self._update_prompt_counter = itertools.count(1) + self._init_model_picker() # Reaction deletion needs the opaque reaction_id from create, cached per message_id. self._pending_processing_reactions: "OrderedDict[str, str]" = OrderedDict() self._load_seen_message_ids() @@ -1755,6 +1759,7 @@ async def delete_message(self, chat_id: str, message_id: str) -> bool: _EA_ACTION_LABELS = {"once": "✅ Allow Once", "session": "✅ Session", "always": "✅ Always", "deny": "❌ Deny"} _EA_CARD_ACTIONS = {"once": "approve_once", "session": "approve_session", "always": "approve_always", "deny": "deny"} + async def _send_exec_approval_prompt(self, prompt: ExecApprovalPrompt) -> SendResult: """Approval-button card; ``hermes_action`` in each button value lets the click callback route to ``resolve_gateway_approval()`` and unblock the waiting agent thread.""" @@ -2186,6 +2191,8 @@ def _on_card_action_trigger(self, data: Any) -> Any: return self._handle_approval_card_action(event=event, action_value=action_value, loop=loop) if action_value.get("hermes_update_prompt_action"): return self._handle_update_prompt_card_action(event=event, action_value=action_value, loop=loop) + if action_value.get("hermes_model_picker"): + return self._handle_model_picker_action(event=event, action_value=action_value, loop=loop) self._submit_on_loop(loop, self._handle_card_action_event(data)) return self._card_response() @@ -2425,6 +2432,7 @@ def _is_card_action_duplicate(self, token: str) -> bool: self._card_action_tokens[token] = now return False + async def _handle_card_action_event(self, data: Any) -> None: """Route Feishu interactive card button clicks as synthetic COMMAND events.""" event = getattr(data, "event", None) diff --git a/plugins/platforms/feishu/model_picker.py b/plugins/platforms/feishu/model_picker.py new file mode 100644 index 0000000000000..03e178e8529cf --- /dev/null +++ b/plugins/platforms/feishu/model_picker.py @@ -0,0 +1,323 @@ +"""Feishu's native model picker; resolution and persistence belong to the gateway.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import threading +import time +import uuid +from typing import Any + +from gateway.platforms.base import SendResult + +logger = logging.getLogger(__name__) +_PICKER_TTL = 15 * 60 +_MAX_PICKERS = 128 + + +def _card(title: str, text: str, actions: list | None = None) -> dict: + elements = [{"tag": "markdown", "content": text}] + for action in actions or []: + elements.append({"tag": "action", "actions": [action]}) + return {"config": {"wide_screen_mode": True}, + "header": {"title": {"tag": "plain_text", "content": title}, "template": "blue"}, + "elements": elements} + + +def _button(label: str, value: dict, style: str = "default") -> dict: + return {"tag": "button", "text": {"tag": "plain_text", "content": label}, + "type": style, "value": value} + + +def _menu(label: str, value: dict, options: list, current: str | None = None) -> dict: + menu = {"tag": "select_static", "placeholder": {"tag": "plain_text", "content": label}, + "value": value, "options": [ + {"text": {"tag": "plain_text", "content": text[:100]}, "value": key} + for key, text in options]} + if current is not None: + menu["initial_option"] = current + return menu + + +def _disambiguate_duplicate_labels(catalog: list) -> None: + """Two credential groups can share one display name (e.g. one relay, several keys). + + Tag the numbered route (slug ``...-2``) so the provider menu never shows twin entries.""" + counts: dict = {} + for item in catalog: + counts[item["name"]] = counts.get(item["name"], 0) + 1 + if not any(count > 1 for count in counts.values()): + return + for item in catalog: + if counts[item["name"]] > 1: + match = re.search(r"-(\d+)$", item["slug"]) + if match: + item["name"] = f"{item['name']}({match.group(1)})" + + +def _picker_text(state: dict, key: str, **fmt) -> str: + """Card copy from the i18n catalogs, in the language the card was sent in (state["lang"]).""" + from agent.i18n import t + return t(f"gateway.model.picker.{key}", lang=(state or {}).get("lang"), **fmt) + + +class FeishuModelPickerMixin: + supports_model_picker_context = True + + def _init_model_picker(self) -> None: + self._model_picker_state: dict[str, dict[str, Any]] = {} + # The SDK callback runs on its own thread; the commit runs on the adapter loop. + self._model_picker_lock = threading.RLock() + + async def send_model_picker( + self, chat_id: str, providers: list, current_model: str, current_provider: str, + session_key: str, on_model_selected, metadata: dict | None = None, + *, picker_context: dict | None = None, + ) -> SendResult: + if not self._client: + return SendResult(success=False, error="Not connected") + context = dict(picker_context or {}) + if not context.get("user_id") or not callable(on_model_selected): + return SendResult(success=False, error="Model picker requires its requesting user") + catalog = [] + for provider in providers or []: + models = list(dict.fromkeys(m for m in provider.get("models", []) if isinstance(m, str) and m))[:20] + if provider.get("slug") and models: + catalog.append({"slug": provider["slug"], "name": provider.get("name") or "Provider", + "models": models, "total_models": provider.get("total_models", len(models))}) + if not catalog: + return SendResult(success=False, error="No authenticated models are available") + _disambiguate_duplicate_labels(catalog) + try: # the card keeps the language it was sent with for its whole lifetime + from agent.i18n import get_language + picker_language = get_language() + except Exception: + picker_language = None + picker_id = uuid.uuid4().hex + state = {"picker_id": picker_id, "session_key": session_key, "chat_id": chat_id, + "message_id": "", "owner_profile": self._owner_profile, "context": context, + "providers": catalog, "current_model": current_model, + "current_provider": current_provider, "provider_index": None, "model_index": None, + "effort": "", "on_model_selected": on_model_selected, + "metadata": dict(metadata or {}), "created": time.monotonic(), "pending": False, + "revision": 0, "update_lock": asyncio.Lock(), "guard_warning": "", + "lang": picker_language} + try: + response = await self._feishu_send_with_retry( + chat_id=chat_id, msg_type="interactive", + payload=json.dumps(self._model_picker_card(state), ensure_ascii=False), + reply_to=None, metadata=metadata, + ) + result = self._finalize_send_result(response, "Model picker send failed") + except Exception: + logger.warning("[Feishu] Model picker send failed", exc_info=True) + return SendResult(success=False, error="Model picker send failed") + if result.success and result.message_id: + state["message_id"] = result.message_id + with self._model_picker_lock: + now = time.monotonic() + self._model_picker_state = { + key: value for key, value in self._model_picker_state.items() + if now - value["created"] < _PICKER_TTL and value["session_key"] != session_key} + while len(self._model_picker_state) >= _MAX_PICKERS: + self._model_picker_state.pop(next(iter(self._model_picker_state))) + self._model_picker_state[picker_id] = state + elif result.success: + return SendResult(success=False, error="Model picker response has no message ID") + return result + + @staticmethod + def _model_picker_card(state: dict) -> dict: + from agent.reasoning_effort import effort_display_label + from hermes_constants import VALID_REASONING_EFFORTS + + def value(action): + # Only opaque IDs/indices cross the wire, never session IDs or provider endpoints. + return {"hermes_model_picker": action, "picker_id": state["picker_id"], "revision": state["revision"]} + + scope = _picker_text(state, "scope_session" if not state["context"].get("persist_global") + else "scope_profile") + profile = state["context"].get("profile_label") or state["owner_profile"] or "default" + text = _picker_text(state, "header", profile=profile, scope=scope, + model=state["current_model"] or "unknown") + "\n" + index = state["provider_index"] + if state.get("guard_warning") and index is not None: + # Selection guard (cost / data-policy) fired on Apply: confirm in-card before the + # switch runs — parity with the typed path's confirm and the other platform pickers'. + provider = state["providers"][index] + model_index = state["model_index"] + model = provider["models"][model_index] if model_index is not None else "" + text += _picker_text(state, "guard_confirm", warning=state["guard_warning"], + model=model, provider=provider["name"]) + actions = [_button(_picker_text(state, "btn_continue"), value("apply_confirm"), "primary"), + _button(_picker_text(state, "btn_back"), value("back"))] + elif index is None: + options = [(str(i), p["name"]) for i, p in enumerate(state["providers"])] + actions = [_menu(_picker_text(state, "menu_provider"), value("provider"), options[start:start + 100]) + for start in range(0, len(options), 100)] + text += _picker_text(state, "hint_pick_provider") + else: + provider = state["providers"][index] + model_index = state["model_index"] + model = provider["models"][model_index] if model_index is not None else "" + options = [(str(i), m) for i, m in enumerate(provider["models"])] + actions = [_menu(_picker_text(state, "menu_model"), value("model"), options, + str(model_index) if model_index is not None else None)] + current = state["context"].get("reasoning_effort") or _picker_text(state, "effort_default") + efforts = [("keep", _picker_text(state, "effort_keep", current=current)), + ("none", _picker_text(state, "effort_none"))] + efforts += [(lv, effort_display_label(lv, provider["slug"], model)) for lv in VALID_REASONING_EFFORTS] + actions.append(_menu(_picker_text(state, "menu_effort"), value("effort"), efforts, state["effort"] or "keep")) + text += _picker_text(state, "hint_pick_model", provider=provider["name"]) + if provider["total_models"] > len(provider["models"]): + text += "\n" + _picker_text(state, "hint_model_limit", count=len(provider["models"])) + if model: + actions.append(_button(_picker_text(state, "btn_apply"), value("apply"), "primary")) + actions.append(_button(_picker_text(state, "btn_back_provider"), value("back"))) + actions.append(_button(_picker_text(state, "btn_cancel"), value("cancel"))) + return _card(_picker_text(state, "title"), text, actions) + + def _handle_model_picker_action(self, *, event, action_value, loop): + picker_id = action_value.get("picker_id") + if not isinstance(picker_id, str): + return self._card_response() + with self._model_picker_lock: + state = self._model_picker_state.get(picker_id) + if not state or state["pending"] or action_value.get("revision") != state["revision"]: + return self._card_response() + if time.monotonic() - state["created"] >= _PICKER_TTL: + self._model_picker_state.pop(picker_id, None) + return self._card_response() + context = getattr(event, "context", None) + operator = getattr(event, "operator", None) + user_ids = {getattr(operator, name, None) for name in ("open_id", "user_id", "union_id")} - {None, ""} + expected_ids = {state["context"].get("user_id"), state["context"].get("user_id_alt")} - {None, ""} + if (not user_ids.intersection(expected_ids) + or getattr(context, "open_chat_id", None) != state["chat_id"] + or getattr(context, "open_message_id", None) != state["message_id"] + or state["owner_profile"] != self._owner_profile): + return self._card_response() + if self._is_sender_authorized(state["context"]["user_id"], state["context"].get("chat_type"), state["chat_id"], + thread_id=state["context"].get("thread_id")) is not True: + return self._card_response() + action = action_value.get("hermes_model_picker") + option = getattr(getattr(event, "action", None), "option", None) + if action in {"provider", "model"}: + if not isinstance(option, str) or not option.isdecimal(): + return self._card_response() + index = int(option) + if action == "provider": + if index >= len(state["providers"]): + return self._card_response() + state["provider_index"], state["model_index"] = index, None + else: + p_index = state["provider_index"] + if p_index is None or index >= len(state["providers"][p_index]["models"]): + return self._card_response() + state["model_index"] = index + state["guard_warning"] = "" # a different pick must be confirmed afresh + elif action == "effort": + from hermes_constants import VALID_REASONING_EFFORTS + if option not in ("keep", "none", *VALID_REASONING_EFFORTS): + return self._card_response() + state["effort"] = "" if option == "keep" else option + elif action == "back": + state["provider_index"], state["model_index"] = None, None + state["guard_warning"] = "" + elif action == "cancel": + self._model_picker_state.pop(picker_id, None) + self._submit_on_loop(loop, self._finish_model_picker( + state, _picker_text(state, "cancelled_title"), _picker_text(state, "cancelled_body"))) + return self._card_response() + elif action in {"apply", "apply_confirm"}: + if state["provider_index"] is None or state["model_index"] is None: + return self._card_response() + state["pending"] = True + # "apply" may bounce into the guard-confirm view; "apply_confirm" is that view's + # own button — never re-guard a selection the user already confirmed. + if not self._submit_on_loop( + loop, self._apply_model_picker(state, check_guard=action == "apply")): + state["pending"] = False + return self._card_response() + # Leave the inline card alone: an asynchronous PATCH may finish before the + # SDK's callback response, which would otherwise overwrite the final result. + return self._card_response() + else: + return self._card_response() + state["revision"] += 1 + self._submit_on_loop(loop, self._refresh_model_picker(state, state["revision"])) + return self._card_response() + + async def _patch_model_picker(self, state: dict, card: dict) -> bool: + # All updates (including completion) share one writer; inline callback + # cards can arrive after a later PATCH and resurrect obsolete controls. + try: + from lark_oapi.api.im.v1 import PatchMessageRequest, PatchMessageRequestBody + body = PatchMessageRequestBody.builder().content(json.dumps(card, ensure_ascii=False)).build() + request = PatchMessageRequest.builder().message_id(state["message_id"]).request_body(body).build() + response = await self._run_blocking(self._client.im.v1.message.patch, request) + return self._response_succeeded(response) + except Exception: + logger.warning("[Feishu] Could not update model picker") + return False + + async def _refresh_model_picker(self, state: dict, revision: int) -> None: + async with state["update_lock"]: + with self._model_picker_lock: + if (self._model_picker_state.get(state["picker_id"]) is not state + or state["pending"] or state["revision"] != revision): + return + card = self._model_picker_card(state) + if not await self._patch_model_picker(state, card): + await self.send(state["chat_id"], _picker_text(state, "update_failed"), metadata=state["metadata"]) + + async def _finish_model_picker(self, state: dict, title: str, reply: str) -> None: + async with state["update_lock"]: + if await self._patch_model_picker(state, _card(title, reply)): + return + result = await self.send(state["chat_id"], reply, metadata=state["metadata"]) + if not result.success: + logger.warning("[Feishu] Could not deliver model picker result") + + async def _model_picker_guard_warning(self, model: str, provider_slug: str) -> str: + """Selection-guard text (cost / data-policy) for the in-card confirm step — parity with + the typed path's guard and the Telegram/Discord pickers'. Empty string when clear.""" + try: + from hermes_cli.model_selection_guards import combined_selection_warning + # Pricing lookup can hit models.dev on a cache miss — keep it off the event loop. + warning = await asyncio.to_thread(combined_selection_warning, model, provider=provider_slug) + except Exception: + warning = None + if warning is None: + return "" + return f"{warning.title}\n{warning.message}" + + async def _apply_model_picker(self, state: dict, *, check_guard: bool = False) -> None: + provider = state["providers"][state["provider_index"]] + model = provider["models"][state["model_index"]] + if check_guard: + warning = await self._model_picker_guard_warning(model, provider["slug"]) + if warning: + with self._model_picker_lock: + state["pending"] = False + state["guard_warning"] = warning + state["revision"] += 1 + revision = state["revision"] + await self._refresh_model_picker(state, revision) + return + try: + reply = await state["on_model_selected"]( + state["chat_id"], model, provider["slug"], state["effort"] + ) + if not isinstance(reply, str) or not reply.strip(): + reply = _picker_text(state, "no_reply") + except Exception: + logger.warning("[Feishu] Model picker callback failed", exc_info=True) + reply = _picker_text(state, "switch_failed") + finally: + with self._model_picker_lock: + self._model_picker_state.pop(state["picker_id"], None) + await self._finish_model_picker(state, _picker_text(state, "result_title"), reply) diff --git a/tests/agent/test_i18n.py b/tests/agent/test_i18n.py index 59f810df81f6e..37cfcd5d006e2 100644 --- a/tests/agent/test_i18n.py +++ b/tests/agent/test_i18n.py @@ -70,6 +70,21 @@ def test_catalog_placeholders_match_english(lang: str): ) +@pytest.mark.parametrize("lang", list(i18n.SUPPORTED_LANGUAGES)) +def test_picker_titles_render_real_newlines(lang: str): + """Picker messages must contain real line breaks in every language. + + A doubled ``\\n`` escape in the catalog YAML parses to a literal backslash-n and the + picker prints ``\n`` on screen (regression for the /fast and /reasoning pickers). + """ + for key, kwargs in (("gateway.fast.picker_title", {"mode": "fast"}), + ("gateway.reasoning.picker_title", + {"level": "high", "scope": "session", "display": "on"})): + value = i18n.t(key, lang=lang, **kwargs) + assert "\\n" not in value, f"{lang} {key}: literal backslash-n in rendered text" + assert "\n" in value, f"{lang} {key}: line breaks missing" + + # --------------------------------------------------------------------------- # Language resolution # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_feishu_model_picker.py b/tests/gateway/test_feishu_model_picker.py new file mode 100644 index 0000000000000..32ee3fb111de9 --- /dev/null +++ b/tests/gateway/test_feishu_model_picker.py @@ -0,0 +1,565 @@ +"""Feishu model-picker contracts: native card controls and safe selection routing.""" + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import PlatformConfig +from hermes_constants import VALID_REASONING_EFFORTS +from plugins.platforms.feishu.adapter import FeishuAdapter + + +def _adapter(): + adapter = FeishuAdapter(PlatformConfig(enabled=True)) + adapter._client = SimpleNamespace() + adapter._owner_profile = "picker-profile" + adapter._feishu_send_with_retry = AsyncMock(return_value=SimpleNamespace( + success=lambda: True, data=SimpleNamespace(message_id="picker-message"), + )) + adapter._card_response = lambda card_data=None: card_data + adapter.set_authorization_check(lambda *_args, **_kwargs: True) + adapter._picker_test_queue = [] + adapter._picker_test_patches = [] + adapter._submit_on_loop = lambda loop, coro: adapter._picker_test_queue.append(coro) or True + def patch_card(request): + adapter._picker_test_patches.append(json.loads(request.request_body.content)) + return SimpleNamespace(success=lambda: True) + adapter._client = SimpleNamespace(im=SimpleNamespace(v1=SimpleNamespace(message=SimpleNamespace(patch=patch_card)))) + async def run_blocking(fn, *args): + return fn(*args) + adapter._run_blocking = run_blocking + return adapter + + +def _actions(card): + return [action for element in card["elements"] if element["tag"] == "action" + for action in element["actions"]] + + +async def _click(adapter, card, action, option=None): + control = next(a for a in _actions(card) if a["value"]["hermes_model_picker"] == action) + response = adapter._handle_model_picker_action(event=_event(control, option=option), + action_value=control["value"], loop=asyncio.get_running_loop()) + assert response is None # SDK acknowledgement must never overwrite an async card PATCH. + while adapter._picker_test_queue: + await adapter._picker_test_queue.pop(0) + return adapter._picker_test_patches[-1] + + +def _event(action, *, option=None, chat_id="picker-chat", message_id="picker-message", open_id="picker-user"): + return SimpleNamespace( + context=SimpleNamespace(open_chat_id=chat_id, open_message_id=message_id), + operator=SimpleNamespace(open_id=open_id, user_id=None, union_id=None), + action=SimpleNamespace(value=action["value"], tag=action["tag"], option=option), + ) + + +async def _send_picker(adapter, providers, callback=None): + result = await adapter.send_model_picker( + chat_id="picker-chat", providers=providers, + current_model="model-0", current_provider="provider-0", + session_key="agent:picker-profile:feishu:dm:picker-chat", + on_model_selected=callback or AsyncMock(return_value="Model switched."), + picker_context={"user_id": "picker-user", "chat_type": "dm", "reasoning_effort": "high"}, + ) + assert result.success + return json.loads(adapter._feishu_send_with_retry.call_args.kwargs["payload"]) + + +@pytest.mark.asyncio +async def test_duplicate_provider_names_are_numbered_in_menu(): + """One relay, two keys: both groups stay listed and are told apart by route suffix.""" + adapter = _adapter() + relay_one = {"slug": "custom:relay", "name": "Relay", "models": ["shared-model"], "total_models": 1} + relay_two = {"slug": "custom:relay-2", "name": "Relay", "models": ["shared-model"], "total_models": 1} + + card = await _send_picker(adapter, [relay_one, relay_two]) + selector = next(action for action in _actions(card) if action["tag"] == "select_static") + assert [option["text"]["content"] for option in selector["options"]] == ["Relay", "Relay(2)"] + + # Which route is listed first must not change the labels. + card = await _send_picker(adapter, [relay_two, relay_one]) + selector = next(action for action in _actions(card) if action["tag"] == "select_static") + assert [option["text"]["content"] for option in selector["options"]] == ["Relay(2)", "Relay"] + + # Unique names stay untouched. + card = await _send_picker(adapter, [relay_one, {"slug": "custom:other", "name": "Other", + "models": ["other-model"], "total_models": 1}]) + selector = next(action for action in _actions(card) if action["tag"] == "select_static") + assert [option["text"]["content"] for option in selector["options"]] == ["Relay", "Other"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("lang,title,provider_label,model_label,effort_label", [ + ("zh", "模型与推理强度", "选择 Provider", "选择模型", "推理强度"), + ("en", "Model & Reasoning", "Select Provider", "Select model", "Reasoning effort"), +]) +async def test_card_text_follows_resolved_language(monkeypatch, lang, title, provider_label, model_label, effort_label): + """The card speaks the profile's display language (both catalogs ship these strings).""" + monkeypatch.setattr("agent.i18n.get_language", lambda: lang) + adapter = _adapter() + card = await _send_picker(adapter, [{"slug": "relay", "name": "Relay", + "models": ["model-a"], "total_models": 1}]) + assert card["header"]["title"]["content"] == title + selector = next(a for a in _actions(card) if a["tag"] == "select_static") + assert selector["placeholder"]["content"] == provider_label + menu_card = await _click(adapter, card, "provider", "0") + placeholders = [a["placeholder"]["content"] for a in _actions(menu_card) if a["tag"] == "select_static"] + assert model_label in placeholders and effort_label in placeholders + + +@pytest.mark.asyncio +async def test_card_language_pinned_when_sent(monkeypatch): + """An open card keeps the language captured at send time; a later change must not repaint it.""" + monkeypatch.setattr("agent.i18n.get_language", lambda: "zh") + adapter = _adapter() + card = await _send_picker(adapter, [{"slug": "relay", "name": "Relay", + "models": ["model-a"], "total_models": 1}]) + assert "选择 Provider" in json.dumps(card, ensure_ascii=False) + monkeypatch.setattr("agent.i18n.get_language", lambda: "en") + refreshed = await _click(adapter, card, "provider", "0") + blob = json.dumps(refreshed, ensure_ascii=False) + assert "选择模型和档位后" in blob + assert "Pick a model and effort" not in blob + + +@pytest.mark.asyncio +async def test_picker_preserves_providers_and_bounds_each_model_menu(): + """Later providers remain reachable without an oversized row of model buttons.""" + adapter = _adapter() + providers = [ + {"slug": f"provider-{index}", "name": f"Provider {index}", + "models": [f"model-{number}" for number in range(25)], "total_models": 25} + for index in range(3) + ] + card = await _send_picker(adapter, providers) + selector = next(action for action in _actions(card) if action["tag"] == "select_static") + assert len(selector["options"]) == len(providers) + assert "\n" in card["elements"][0]["content"] + assert "\\n" not in card["elements"][0]["content"] + for option in selector["options"]: + models_card = await _click(adapter, card, "provider", option["value"]) + model_selector = next(action for action in _actions(models_card) if action["tag"] == "select_static") + assert len(model_selector["options"]) == 20 + assert any(action["value"]["hermes_model_picker"] == "back" for action in _actions(models_card)) + assert all(len(element["actions"]) <= 5 for element in models_card["elements"] + if element["tag"] == "action") + card = await _click(adapter, models_card, "back") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("effort,scenario", [(e, "session") for e in (*VALID_REASONING_EFFORTS, "none", "keep")] + + [("ultra", s) for s in ("global", "global-failure", "rotated", "revoked", "failure")]) +async def test_gateway_binds_requester_and_commits_effort_with_model(tmp_path, monkeypatch, effort, scenario): + """Every visible effort (including ultra) reaches the real gateway commit unchanged.""" + from gateway.config import GatewayConfig, Platform + from gateway.platforms.event import MessageEvent + from gateway.run import GatewayRunner + from gateway.session import SessionSource, SessionStore + from hermes_cli.model_switch import ModelSwitchResult + import gateway.run as gateway_run + + home = tmp_path / "picker-home" + home.mkdir() + (home / "config.yaml").write_text("model:\n default: old-model\n provider: custom\nagent:\n reasoning_effort: high\n") + monkeypatch.setattr(gateway_run, "_hermes_home", home) + monkeypatch.setenv("HERMES_HOME", str(home)) + adapter = _adapter() + adapter._loop = asyncio.get_running_loop() + adapter.send = AsyncMock(return_value=SimpleNamespace(success=True)) + patches = adapter._picker_test_patches + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig() + runner.adapters = {Platform.FEISHU: adapter} + runner._voice_mode = {} + runner._session_model_overrides = {} + runner._session_reasoning_overrides = {} + runner._running_agents = {} + runner._session_db = None + runner._is_user_authorized = lambda *_a, **_kw: True + runner.session_store = SessionStore(sessions_dir=home / "sessions", config=runner.config) + source = SessionSource(platform=Platform.FEISHU, chat_id="picker-chat", chat_type="dm", user_id="picker-user") + key = runner._session_key_for_source(source) + runner.session_store.get_or_create_session(source) + monkeypatch.setattr("hermes_cli.model_switch_providers.list_picker_providers", lambda **kwargs: [ + {"slug": "provider-0", "name": "Provider", "models": ["model-0", "model-1"]}]) + # The card copies come from the i18n catalogs (zh profile display language here). + monkeypatch.setattr("agent.i18n.get_language", lambda: "zh") + # Resolution is the external seam; commit, session storage and reasoning parsing are real. + async def perform(ctx, model_id, provider, source): + if scenario == "failure": + return None, "Resolution rejected." + return ModelSwitchResult(success=True, new_model=model_id, target_provider=provider, is_global=ctx.persist_global), None + runner._perform_model_switch = perform + runner._model_switch_confirmation = AsyncMock(return_value="Model applied.") + runner._delivery_adapter_for = lambda source: adapter + if scenario == "global-failure": + def fail_write(*args, **kwargs): + raise OSError("synthetic config write failure") + # The paired model+effort persistence is ONE targeted read-modify-write; fail it there. + monkeypatch.setattr("utils.atomic_roundtrip_yaml_update_multi", fail_write) + + for effort in (effort,): + event = MessageEvent(text="/model --global" if scenario.startswith("global") else "/model", source=source) + assert await runner._handle_model_command(event) is None + card = json.loads(adapter._feishu_send_with_retry.call_args.kwargs["payload"]) + if scenario.startswith("global"): + assert "此机器人的默认设置" in card["elements"][0]["content"] + for action_name, option in (("provider", "0"), ("model", "1"), ("effort", effort)): + control = next(a for a in _actions(card) if a["value"]["hermes_model_picker"] == action_name) + if action_name == "effort": + assert {o["value"] for o in control["options"]} == {"keep", "none", *VALID_REASONING_EFFORTS} + assert effort in {o["value"] for o in control["options"]} + assert adapter._on_card_action_trigger(SimpleNamespace(event=_event(control, option=option))) is None + while adapter._picker_test_queue: + await adapter._picker_test_queue.pop(0) + card = patches[-1] + apply = next(a for a in _actions(card) if a["value"]["hermes_model_picker"] == "apply") + submitted = [] + def submit(loop, coro): + submitted.append(coro) + return True + adapter._submit_on_loop = submit + if scenario == "rotated": + runner.session_store.get_or_create_session(source, force_new=True) + if scenario == "revoked": + runner._is_user_authorized = lambda *_a, **_kw: False + adapter._on_card_action_trigger(SimpleNamespace(event=_event(apply))) + adapter._on_card_action_trigger(SimpleNamespace(event=_event(apply))) + assert len(submitted) == 1 + await submitted[0] + if scenario in {"rotated", "revoked", "failure"}: + assert not runner._session_model_overrides + assert not runner._session_reasoning_overrides + assert "old-model" in (home / "config.yaml").read_text() + return + if scenario == "global": + import hermes_yaml as yaml + saved = yaml.safe_load((home / "config.yaml").read_text()) + assert saved["model"]["default"] == "model-1" + assert saved["agent"]["reasoning_effort"] == effort + assert not runner._session_model_overrides + assert not runner._session_reasoning_overrides + return + assert runner._session_model_overrides[key]["model"] == "model-1" + if effort == "keep": + assert key not in runner._session_reasoning_overrides + else: + expected = {"enabled": False} if effort == "none" else {"enabled": True, "effort": effort} + assert runner._session_reasoning_overrides[key] == expected + restored = SessionStore(sessions_dir=home / "sessions", config=runner.config) + assert restored.get_model_override(key)["model"] == "model-1" + if effort != "keep": + assert effort in patches[-1]["elements"][0]["content"] + assert not adapter._model_picker_state + assert "old-model" in (home / "config.yaml").read_text() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("invalid", ["user", "chat", "message", "profile", "auth", "expired", "superseded"]) +async def test_picker_rejects_unbound_actions(invalid, monkeypatch): + adapter = _adapter() + callback = AsyncMock() + providers = [{"slug": "relay", "models": ["model-1"]}] + card = await _send_picker(adapter, providers, callback) + control = _actions(card)[0] + event = _event(control, option="0") + before = next(iter(adapter._model_picker_state.values())) + if invalid == "user": + event.operator.open_id = "other-user" + elif invalid == "chat": + event.context.open_chat_id = "other-chat" + elif invalid == "message": + event.context.open_message_id = "other-message" + elif invalid == "profile": + adapter._owner_profile = "other-profile" + elif invalid == "auth": + adapter.set_authorization_check(lambda *_a, **_kw: False) + elif invalid == "expired": + before["created"] -= 1000 + elif invalid == "superseded": + await _send_picker(adapter, providers, callback) + adapter._on_card_action_trigger(SimpleNamespace(event=event)) + assert before["provider_index"] is None + callback.assert_not_called() + + +@pytest.mark.asyncio +async def test_legacy_picker_adapter_keeps_original_signature(monkeypatch): + from gateway.slash_commands_model import GatewayModelCommandsMixin + class LegacyAdapter: + async def send_model_picker(self, chat_id, providers, current_model, current_provider, + session_key, on_model_selected, metadata=None): + return SimpleNamespace(success=True) + runner = GatewayModelCommandsMixin() + runner._thread_metadata_for_source = lambda *_a: None + runner._reply_anchor_for_event = lambda *_a: None + runner._resolve_session_reasoning_config = lambda **_kw: {} + source = SimpleNamespace(chat_id="picker-chat") + monkeypatch.setattr("hermes_cli.model_switch_providers.list_picker_providers", lambda **_k: [{"slug": "relay", "models": ["model"]}]) + assert await runner._send_model_picker(None, source, LegacyAdapter(), "session-key", + {"current_provider": "relay", "current_model": "model"}, AsyncMock()) + + +@pytest.mark.asyncio +async def test_picker_uses_bound_primary_identity_and_thread(): + adapter = _adapter() + seen = [] + adapter.set_authorization_check(lambda user_id, chat_type, chat_id, **kw: + seen.append((user_id, kw.get("thread_id"))) or user_id == "tenant-user") + await adapter.send_model_picker( + "picker-chat", [{"slug": "relay", "models": ["model"]}], "model", "relay", "session", AsyncMock(), + picker_context={"user_id": "tenant-user", "user_id_alt": "union-user", "chat_type": "group", "thread_id": "topic-a"}) + card = json.loads(adapter._feishu_send_with_retry.call_args.kwargs["payload"]) + control = _actions(card)[0] + event = _event(control, option="0", open_id="app-user") + event.operator.user_id = "tenant-user" + event.operator.union_id = "union-user" + adapter._handle_model_picker_action(event=event, action_value=control["value"], loop=asyncio.get_running_loop()) + while adapter._picker_test_queue: + await adapter._picker_test_queue.pop(0) + assert seen == [("tenant-user", "topic-a")] + assert next(iter(adapter._model_picker_state.values()))["provider_index"] == 0 + + +@pytest.mark.asyncio +async def test_delayed_model_callback_cannot_select_a_different_provider(): + adapter = _adapter() + card = await _send_picker(adapter, [{"slug": "a", "models": ["model-a"]}, + {"slug": "b", "models": ["model-b"]}]) + first = await _click(adapter, card, "provider", "0") + delayed = next(a for a in _actions(first) if a["value"]["hermes_model_picker"] == "model") + back = await _click(adapter, first, "back") + await _click(adapter, back, "provider", "1") + adapter._handle_model_picker_action(event=_event(delayed, option="0"), + action_value=delayed["value"], loop=asyncio.get_running_loop()) + state = next(iter(adapter._model_picker_state.values())) + assert state["provider_index"] == 1 + assert state["model_index"] is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("write_fails", [False, True]) +async def test_model_and_effort_are_one_config_write(tmp_path, monkeypatch, write_fails): + import hermes_yaml as yaml + import utils + from gateway.slash_commands_model import _persist_model_switch_to_config + from hermes_cli.model_switch import ModelSwitchResult + path = tmp_path / "config.yaml" + before = {"model": {"default": "old", "provider": "custom:old"}, + "agent": {"reasoning_effort": "low"}, "marker": "keep"} + path.write_text(yaml.safe_dump(before)) + writes = [] + original = utils.atomic_roundtrip_yaml_update_multi + def write(target, updates, **kwargs): + writes.append(dict(updates)) + assert updates["model.default"] == "new" + assert updates["agent.reasoning_effort"] == "ultra" + if write_fails: + raise OSError("synthetic disk failure") + return original(target, updates, **kwargs) + monkeypatch.setattr(utils, "atomic_roundtrip_yaml_update_multi", write) + result = ModelSwitchResult(success=True, new_model="new", target_provider="custom:new") + if write_fails: + with pytest.raises(OSError): + await _persist_model_switch_to_config(result, path, reasoning_effort="ultra") + assert yaml.safe_load(path.read_text()) == before + else: + await _persist_model_switch_to_config(result, path, reasoning_effort="ultra") + saved = yaml.safe_load(path.read_text()) + assert saved["model"]["default"] == "new" + assert saved["agent"]["reasoning_effort"] == "ultra" + assert saved["marker"] == "keep" + assert len(writes) == 1 + + +@pytest.mark.asyncio +async def test_global_persist_does_not_revert_concurrent_writer(tmp_path, monkeypatch): + """A /fast-style --global save landing between the selection's read and its write must survive + that write: the selection only applies its own dotted keys, never a stale whole-doc snapshot.""" + import hermes_yaml as yaml + import hermes_cli.config as config + from hermes_cli.model_switch import ModelSwitchResult, persist_model_selection + path = tmp_path / "config.yaml" + path.write_text(yaml.safe_dump({"model": {"default": "old", "provider": "custom:old"}, "marker": "keep"})) + real_read = config.read_user_config_raw + def read_then_other_writer_saves(*args, **kwargs): + data = real_read(*args, **kwargs) + # Another --global writer (/fast) persists right after this read, before ours lands. + interleaved = yaml.safe_load(path.read_text()) + interleaved.setdefault("agent", {})["service_tier"] = "fast" + path.write_text(yaml.safe_dump(interleaved)) + return data + monkeypatch.setattr(config, "read_user_config_raw", read_then_other_writer_saves) + result = ModelSwitchResult(success=True, new_model="new", target_provider="custom:new") + persist_model_selection(result, path) + saved = yaml.safe_load(path.read_text()) + assert saved["agent"]["service_tier"] == "fast" + assert saved["model"]["default"] == "new" + assert saved["marker"] == "keep" + + +@pytest.mark.asyncio +async def test_picker_apply_rejected_while_agent_running(tmp_path, monkeypatch): + """A delayed card click must not hot-swap the agent an in-flight turn is streaming from — + the typed /model path is rejected mid-run with the same text (run_busy._BUSY_REJECT_TEXT).""" + from gateway.config import GatewayConfig, Platform + from gateway.platforms.event import MessageEvent + from gateway.run import GatewayRunner + from gateway.session import SessionSource, SessionStore + from hermes_cli.model_switch import ModelSwitchResult + import gateway.run as gateway_run + + home = tmp_path / "picker-home" + home.mkdir() + (home / "config.yaml").write_text("model:\n default: old-model\n provider: custom\n") + monkeypatch.setattr(gateway_run, "_hermes_home", home) + monkeypatch.setenv("HERMES_HOME", str(home)) + adapter = _adapter() + adapter._loop = asyncio.get_running_loop() + adapter.send = AsyncMock(return_value=SimpleNamespace(success=True)) + patches = adapter._picker_test_patches + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig() + runner.adapters = {Platform.FEISHU: adapter} + runner._voice_mode = {} + runner._session_model_overrides = {} + runner._session_reasoning_overrides = {} + runner._running_agents = {} + runner._session_db = None + runner._is_user_authorized = lambda *_a, **_kw: True + runner.session_store = SessionStore(sessions_dir=home / "sessions", config=runner.config) + source = SessionSource(platform=Platform.FEISHU, chat_id="picker-chat", chat_type="dm", user_id="picker-user") + key = runner._session_key_for_source(source) + runner.session_store.get_or_create_session(source) + monkeypatch.setattr("hermes_cli.model_switch_providers.list_picker_providers", lambda **kwargs: [ + {"slug": "provider-0", "name": "Provider", "models": ["model-0", "model-1"]}]) + performed = [] + async def perform(ctx, model_id, provider, source): + performed.append(model_id) + return ModelSwitchResult(success=True, new_model=model_id, target_provider=provider, + is_global=ctx.persist_global), None + runner._perform_model_switch = perform + runner._model_switch_confirmation = AsyncMock(return_value="Model applied.") + runner._delivery_adapter_for = lambda source: adapter + + assert await runner._handle_model_command(MessageEvent(text="/model", source=source)) is None + card = json.loads(adapter._feishu_send_with_retry.call_args.kwargs["payload"]) + for action_name, option in (("provider", "0"), ("model", "1")): + control = next(a for a in _actions(card) if a["value"]["hermes_model_picker"] == action_name) + adapter._on_card_action_trigger(SimpleNamespace(event=_event(control, option=option))) + while adapter._picker_test_queue: + await adapter._picker_test_queue.pop(0) + card = patches[-1] + runner._running_agents[key] = object() # a turn is streaming for this session + apply = next(a for a in _actions(card) if a["value"]["hermes_model_picker"] == "apply") + submitted = [] + adapter._submit_on_loop = lambda loop, coro: submitted.append(coro) or True + adapter._on_card_action_trigger(SimpleNamespace(event=_event(apply))) + assert len(submitted) == 1 + await submitted[0] + assert performed == [] # resolution never ran + assert "Agent is running" in patches[-1]["elements"][0]["content"] + assert not runner._session_model_overrides + assert not runner._session_reasoning_overrides + assert "old-model" in (home / "config.yaml").read_text() + + +@pytest.mark.asyncio +async def test_new_session_during_commit_does_not_take_the_old_effort(tmp_path, monkeypatch): + """`/new` can land while the card's commit awaits metadata; the fresh session must not + receive the old card's model override or reasoning pin.""" + from gateway.config import GatewayConfig, Platform + from gateway.platforms.event import MessageEvent + from gateway.run import GatewayRunner + from gateway.session import SessionSource, SessionStore + from hermes_cli.model_switch import ModelSwitchResult + import gateway.run as gateway_run + + home = tmp_path / "picker-home" + home.mkdir() + (home / "config.yaml").write_text("model:\n default: old-model\n provider: custom\n") + monkeypatch.setattr(gateway_run, "_hermes_home", home) + monkeypatch.setenv("HERMES_HOME", str(home)) + adapter = _adapter() + adapter._loop = asyncio.get_running_loop() + adapter.send = AsyncMock(return_value=SimpleNamespace(success=True)) + patches = adapter._picker_test_patches + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig() + runner.adapters = {Platform.FEISHU: adapter} + runner._voice_mode = {} + runner._session_model_overrides = {} + runner._session_reasoning_overrides = {} + runner._running_agents = {} + runner._session_db = None + runner._is_user_authorized = lambda *_a, **_kw: True + runner.session_store = SessionStore(sessions_dir=home / "sessions", config=runner.config) + source = SessionSource(platform=Platform.FEISHU, chat_id="picker-chat", chat_type="dm", user_id="picker-user") + key = runner._session_key_for_source(source) + runner.session_store.get_or_create_session(source) + monkeypatch.setattr("hermes_cli.model_switch_providers.list_picker_providers", lambda **kwargs: [ + {"slug": "provider-0", "name": "Provider", "models": ["model-0", "model-1"]}]) + # The stale-session notice also comes from the i18n catalogs. + monkeypatch.setattr("agent.i18n.get_language", lambda: "zh") + async def perform(ctx, model_id, provider, source): + return ModelSwitchResult(success=True, new_model=model_id, target_provider=provider, + is_global=ctx.persist_global), None + runner._perform_model_switch = perform + async def confirm(*_a, **_kw): + # `/new` lands mid-commit: the session rotates (its conversation scope clears) while + # the commit awaits its metadata lookup. + runner.session_store.get_or_create_session(source, force_new=True) + runner._session_model_overrides.clear() + runner._session_reasoning_overrides.clear() + return "Model applied." + runner._model_switch_confirmation = confirm + runner._delivery_adapter_for = lambda source: adapter + + assert await runner._handle_model_command(MessageEvent(text="/model", source=source)) is None + card = json.loads(adapter._feishu_send_with_retry.call_args.kwargs["payload"]) + for action_name, option in (("provider", "0"), ("model", "1"), ("effort", "ultra")): + control = next(a for a in _actions(card) if a["value"]["hermes_model_picker"] == action_name) + adapter._on_card_action_trigger(SimpleNamespace(event=_event(control, option=option))) + while adapter._picker_test_queue: + await adapter._picker_test_queue.pop(0) + card = patches[-1] + apply = next(a for a in _actions(card) if a["value"]["hermes_model_picker"] == "apply") + submitted = [] + adapter._submit_on_loop = lambda loop, coro: submitted.append(coro) or True + adapter._on_card_action_trigger(SimpleNamespace(event=_event(apply))) + assert len(submitted) == 1 + await submitted[0] + assert key not in runner._session_reasoning_overrides + assert key not in runner._session_model_overrides + assert "已变化" in patches[-1]["elements"][0]["content"] + + +@pytest.mark.asyncio +async def test_guarded_model_requires_card_confirmation(monkeypatch): + """A cost/data-policy guarded pick must confirm in the card before switching — parity with + the typed guard and the Telegram/Discord pickers' "Switch anyway" step.""" + adapter = _adapter() + callback = AsyncMock(return_value="Model switched.") + card = await _send_picker(adapter, [{"slug": "relay", "models": ["guarded-model"]}], callback) + card = await _click(adapter, card, "provider", "0") + card = await _click(adapter, card, "model", "0") + import hermes_cli.model_selection_guards as guards + def fake_warning(model_name, **kwargs): + if model_name == "guarded-model": + return SimpleNamespace(title="Data-Training Tier Warning", + message="This model may train on your data.") + return None + monkeypatch.setattr(guards, "combined_selection_warning", fake_warning) + + confirm_card = await _click(adapter, card, "apply") + callback.assert_not_called() + assert "train on your data" in confirm_card["elements"][0]["content"] + assert any(a["value"]["hermes_model_picker"] == "apply_confirm" for a in _actions(confirm_card)) + + await _click(adapter, confirm_card, "apply_confirm") + callback.assert_called_once() + diff --git a/tests/gateway/test_model_command_reasoning_flag.py b/tests/gateway/test_model_command_reasoning_flag.py index 306df0e0c1f30..89d264ad5047e 100644 --- a/tests/gateway/test_model_command_reasoning_flag.py +++ b/tests/gateway/test_model_command_reasoning_flag.py @@ -2,7 +2,7 @@ applier ``/reasoning`` uses (session override by default, ``agent.reasoning_effort`` on --global).""" from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest @@ -10,14 +10,15 @@ from gateway.slash_commands_model import _ModelSwitchContext -def _runner(): +def _runner(save_error=None): from gateway.run import GatewayRunner runner = object.__new__(GatewayRunner) calls = {} runner._switch_cached_agent_model = lambda *_a, **_k: None - runner._record_model_switch = AsyncMock(return_value=None) # None = config write succeeded + runner._record_model_switch = AsyncMock(return_value=save_error) # None = config write succeeded runner._model_switch_confirmation = AsyncMock(return_value="switched") + runner._set_reasoning_override = Mock(side_effect=lambda key, value: calls.setdefault("override", (key, value))) runner._apply_reasoning_selection = ( lambda session_key, platform_key, value, persist_global=False: calls.setdefault("applied", (session_key, platform_key, value, persist_global)) and "effort set") @@ -25,17 +26,25 @@ def _runner(): @pytest.mark.asyncio -async def test_reasoning_flag_applies_after_the_switch_with_the_pick_scope(): - runner, calls = _runner() +@pytest.mark.parametrize("persist_global,save_error", [(True, None), (False, None), (True, "disk failure")]) +async def test_reasoning_flag_applies_after_the_switch_with_the_pick_scope(persist_global, save_error): + runner, calls = _runner(save_error) ctx = _ModelSwitchContext(session_key="telegram:c1", source=None, config_path=None, - persist_global=True, reasoning_effort="high") + persist_global=persist_global, reasoning_effort="high") result = SimpleNamespace(new_model="m", target_provider="nous") source = SimpleNamespace(platform=Platform.TELEGRAM) reply = await runner._commit_model_switch(result, ctx, source=source) - assert calls["applied"] == ("telegram:c1", "telegram", "high", True) - assert reply == "switched\neffort set" + if persist_global and save_error is None: + # The paired config write already persisted reasoning: update memory only. + assert "applied" not in calls + assert calls["override"] == ("telegram:c1", None) + assert runner._reasoning_config == {"enabled": True, "effort": "high"} + assert "high" in reply + else: + assert calls["applied"] == ("telegram:c1", "telegram", "high", False) + assert reply == "switched\neffort set" @pytest.mark.asyncio diff --git a/tests/hermes_cli/test_apply_model_switch_result_context.py b/tests/hermes_cli/test_apply_model_switch_result_context.py index d72baa59280fa..aad242dde635c 100644 --- a/tests/hermes_cli/test_apply_model_switch_result_context.py +++ b/tests/hermes_cli/test_apply_model_switch_result_context.py @@ -125,15 +125,25 @@ def test_picker_path_falls_back_to_model_info_when_resolver_empty(monkeypatch): ) -def test_global_switch_clears_context_pin_owned_by_previous_route(monkeypatch): +def test_global_switch_clears_context_pin_owned_by_previous_route(tmp_path, monkeypatch): + """The pin belonged to the previous route; the global save (one targeted atomic write) must + drop it, sync the new endpoint and leave sibling settings alone.""" + import hermes_yaml as yaml import cli as cli_mod - writes = [] + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setattr(cli_mod, "_cprint", lambda *_a, **_k: None) - monkeypatch.setattr( - "utils.atomic_roundtrip_yaml_update", - lambda path, key, value: writes.append((key, value)), - ) + path = tmp_path / "config.yaml" + configured = { + "model": { + "default": "shared-model", + "provider": "custom", + "base_url": "https://large.example/v1", + "context_length": 1_048_576, + }, + "marker": "keep", + } + path.write_text(yaml.safe_dump(configured)) cli = _StubCLI() cli.model = "shared-model" cli.provider = "custom" @@ -155,21 +165,14 @@ def test_global_switch_clears_context_pin_owned_by_previous_route(monkeypatch): is_global=True, ) - configured = { - "model": { - "default": "shared-model", - "provider": "custom", - "base_url": "https://large.example/v1", - "context_length": 1_048_576, - } - } - with ( - patch( - "agent.model_metadata.get_model_context_length", - return_value=256_000, - ), - patch("hermes_cli.config.read_user_config_raw", return_value=configured), + with patch( + "agent.model_metadata.get_model_context_length", + return_value=256_000, ): cli_mod.HermesCLI._apply_model_switch_result(cli, result, True) - assert ("model.context_length", None) in writes + saved = yaml.safe_load(path.read_text()) + assert saved["model"]["default"] == "shared-model" + assert saved["model"]["base_url"] == "https://small.example/v1" + assert "context_length" not in saved["model"] + assert saved["marker"] == "keep" diff --git a/tests/hermes_cli/test_global_switch_persists_base_url_api_mode.py b/tests/hermes_cli/test_global_switch_persists_base_url_api_mode.py index dd7be0d3b682c..3ddbe38acba8e 100644 --- a/tests/hermes_cli/test_global_switch_persists_base_url_api_mode.py +++ b/tests/hermes_cli/test_global_switch_persists_base_url_api_mode.py @@ -13,8 +13,7 @@ surface. """ -from unittest.mock import MagicMock, patch - +import hermes_yaml as yaml import pytest from hermes_cli.model_switch import ModelSwitchResult @@ -78,40 +77,46 @@ def _run_switch(monkeypatch, result, cmd="/model MiniMax-M3 --global"): import cli as cli_mod monkeypatch.setattr(cli_mod, "_cprint", lambda *a, **k: None) - saved: dict[str, object] = {} - - def _fake_save(path, key, value): - saved[key] = value - - monkeypatch.setattr("utils.atomic_roundtrip_yaml_update", _fake_save) monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **kw: result) monkeypatch.setattr( "hermes_cli.inventory.load_picker_context", lambda: (_ for _ in ()).throw(RuntimeError("no picker context in test")), ) cli_mod.HermesCLI._handle_model_switch(_StubCLI(), cmd) - return saved -def test_global_switch_persists_base_url_and_api_mode(monkeypatch): +@pytest.mark.parametrize("picker", [False, True]) +def test_global_switch_persists_base_url_and_api_mode(tmp_path, monkeypatch, picker): """The core #25106 fix: a --global switch to a new provider/endpoint must write the newly resolved base_url and api_mode, not just default/provider.""" - saved = _run_switch(monkeypatch, _make_result()) - - assert saved["model.default"] == "MiniMax-M3" - assert saved["model.provider"] == "custom:minimax" - assert saved["model.base_url"] == "https://api.minimax.io/v1" - assert saved["model.api_mode"] == "chat_completions" - - -def test_session_only_switch_does_not_touch_config(monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + path = tmp_path / "config.yaml" + before = {"model": {"default": "old-model", "provider": "copilot", "base_url": "https://old.example/v1"}, + "marker": "keep", "providers": {"fixture": {"api_key": "${FIXTURE_KEY}"}}} + path.write_text(yaml.safe_dump(before)) + if picker: + _run_apply(monkeypatch, _make_result()) + else: + _run_switch(monkeypatch, _make_result()) + saved = yaml.safe_load(path.read_text()) + assert saved["model"]["default"] == "MiniMax-M3" + assert saved["model"]["provider"] == "custom:minimax" + assert saved["model"]["base_url"] == "https://api.minimax.io/v1" + assert saved["model"]["api_mode"] == "chat_completions" + assert saved["marker"] == before["marker"] + assert saved["providers"] == before["providers"] + + +def test_session_only_switch_does_not_touch_config(tmp_path, monkeypatch): """--session must not write config.yaml at all — persistence stays entirely in-memory.""" import cli as cli_mod monkeypatch.setattr(cli_mod, "_cprint", lambda *a, **k: None) - save_calls = [] - monkeypatch.setattr("utils.atomic_roundtrip_yaml_update", lambda *a, **k: save_calls.append(a)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + path = tmp_path / "config.yaml" + before = "# preserved exactly\nmodel: old-model\n" + path.write_text(before) monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **kw: _make_result()) monkeypatch.setattr( "hermes_cli.inventory.load_picker_context", @@ -120,7 +125,7 @@ def test_session_only_switch_does_not_touch_config(monkeypatch): cli_mod.HermesCLI._handle_model_switch(_StubCLI(), "/model MiniMax-M3 --session") - assert save_calls == [] + assert path.read_text() == before def _run_apply(monkeypatch, result, persist_global=True): @@ -132,14 +137,7 @@ def _run_apply(monkeypatch, result, persist_global=True): import cli as cli_mod monkeypatch.setattr(cli_mod, "_cprint", lambda *a, **k: None) - saved: dict[str, object] = {} - - def _fake_save(path, key, value): - saved[key] = value - - monkeypatch.setattr("utils.atomic_roundtrip_yaml_update", _fake_save) cli_mod.HermesCLI._apply_model_switch_result(_StubCLI(), result, persist_global) - return saved diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 0996d4a1748aa..afccf1ccc2efe 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -1263,9 +1263,9 @@ def test_list_authenticated_providers_distinct_endpoints_stay_separate(monkeypat def test_list_authenticated_providers_same_url_different_keys_disambiguated(monkeypatch): - """Two custom_providers entries with the same base_url but different - api_keys (and identical cleaned names) must both stay visible in the - picker — slug is suffixed to disambiguate.""" + """Two custom_providers entries with the same base_url but different api_keys must + both stay visible: each row shows the display name exactly as the user gave it + ("OpenAI — key A" / "OpenAI — key B"), and the slug is still suffixed.""" monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) @@ -1289,6 +1289,35 @@ def test_list_authenticated_providers_same_url_different_keys_disambiguated(monk models = {p["slug"]: p["models"] for p in custom_groups} assert models["custom:openai"] == ["gpt-5.4"] assert models["custom:openai-2"] == ["gpt-4.6"] + # Single-entry rows show the display name as given — no suffix stripping. + assert sorted(p["name"] for p in custom_groups) == ["OpenAI — key A", "OpenAI — key B"] + + +def test_same_named_relay_keys_remain_visible_and_resolvable(monkeypatch): + """Same name/URL/model entries remain separate when only key_env differs.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + + config = {"custom_providers": [ + {"name": "Relay", "base_url": "https://relay.example/v1", "key_env": "RELAY_KEY_A", "model": "shared-model"}, + {"name": "Relay", "base_url": "https://relay.example/v1", "key_env": "RELAY_KEY_B", "model": "shared-model"}, + ]} + from hermes_cli.config import get_compatible_custom_providers + compatible = get_compatible_custom_providers(config) + assert [entry["key_env"] for entry in compatible] == ["RELAY_KEY_A", "RELAY_KEY_B"] + + providers = list_authenticated_providers( + user_providers={}, custom_providers=compatible, max_models=50, + probe_custom_providers=False, + ) + custom = [row for row in providers if row.get("is_user_defined")] + assert {row["slug"] for row in custom} == {"custom:relay", "custom:relay-2"} + assert all(row["models"] == ["shared-model"] for row in custom) + + second = resolve_provider_full("custom:relay-2", {}, compatible) + assert second is not None + assert second.id == "custom:relay-2" + assert second.api_key_env_vars == ("RELAY_KEY_B",) def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch): @@ -1777,6 +1806,7 @@ def test_model_flow_named_custom_persists_discovered_models(monkeypatch): ) from hermes_cli.model_setup_flows_custom import _model_flow_named_custom + from hermes_cli.custom_provider_identity import credential_identity _model_flow_named_custom( {}, @@ -1801,7 +1831,7 @@ def test_model_flow_named_custom_persists_discovered_models(monkeypatch): { "api_mode": "anthropic_messages", "headers": {"X-Tenant": "dragomes"}, - "credential_identity": "sk-test", + "credential_identity": credential_identity({"api_key": "sk-test"}), }, ) ], ( diff --git a/tests/hermes_cli/test_model_switch_reasoning_flag.py b/tests/hermes_cli/test_model_switch_reasoning_flag.py index 567d80763502d..7001922be49e6 100644 --- a/tests/hermes_cli/test_model_switch_reasoning_flag.py +++ b/tests/hermes_cli/test_model_switch_reasoning_flag.py @@ -7,6 +7,9 @@ from types import SimpleNamespace +import hermes_yaml as yaml +import pytest + from hermes_cli.model_switch import ( MODEL_SWITCH_ERR_BAD_REASONING, ModelSwitchResult, @@ -28,7 +31,7 @@ def test_reasoning_flag_rides_with_the_pick_and_validates(): assert parse_model_switch_args("sonnet \u2014reasoning low").reasoning_effort == "low" -def test_cli_commit_applies_effort_after_the_agent_swap(monkeypatch): +def test_cli_commit_applies_effort_after_the_agent_swap(tmp_path, monkeypatch): """The agent's switch_model resets reasoning_config from config; the ride-along effort must win over that reset, on both the CLI and the live agent.""" import cli as cli_mod @@ -50,16 +53,63 @@ def switch_model(self, **_kw): cli._stage_and_swap_model = lambda result, old: cli_mod.HermesCLI._stage_and_swap_model(cli, result, old) monkeypatch.setattr(mixin, "_print_switch_summary", lambda *_a, **_k: None) monkeypatch.setattr(cli_mod.HermesCLI, "_persist_model_switch_to_session", lambda *_a: None) - saved = {} - monkeypatch.setattr(cli_mod, "save_config_value", lambda k, v: saved.setdefault(k, v) or True) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + path = tmp_path / "config.yaml" + before = {"model": {"default": "old", "provider": "nous"}, "agent": {"reasoning_effort": "low"}} + path.write_text(yaml.safe_dump(before)) result = ModelSwitchResult(success=True, new_model="new", target_provider="nous") mixin._commit_model_switch(cli, result, persist_global=False, reasoning_effort="high") assert agent.reasoning_config == {"enabled": True, "effort": "high"} assert cli.reasoning_config == {"enabled": True, "effort": "high"} - assert "agent.reasoning_effort" not in saved # session scope: no config write + assert yaml.safe_load(path.read_text()) == before mixin._commit_model_switch(cli, result, persist_global=True, reasoning_effort="none") - assert saved.get("agent.reasoning_effort") == "none" + assert yaml.safe_load(path.read_text())["agent"]["reasoning_effort"] == "none" assert agent.reasoning_config == {"enabled": False} + + +@pytest.mark.parametrize("write_fails", [False, True]) +def test_cli_global_model_and_effort_use_one_atomic_persist(tmp_path, monkeypatch, write_fails): + """A global model pick and its effort must share one config write.""" + import cli as cli_mod + from hermes_cli import cli_model_switch_mixin as mixin + + cli = SimpleNamespace( + model="old", provider="nous", agent=None, _pending_one_turn_model_restore=None, + _snapshot_model_runtime=lambda: {}, _persist_model_switch_to_session=lambda *_a: None, + _stage_and_swap_model=lambda *_a: True, + ) + monkeypatch.setattr(mixin, "_print_switch_summary", lambda *_a, **_k: None) + monkeypatch.setattr(cli_mod, "_cprint", lambda *_a, **_k: None) + monkeypatch.setattr(cli_mod, "save_config_value", lambda *_a, **_k: (_ for _ in ()).throw( + AssertionError("reasoning must be part of the model write"))) + import utils + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + path = tmp_path / "config.yaml" + before = {"model": {"default": "old", "provider": "nous"}, "agent": {"reasoning_effort": "low"}} + path.write_text(yaml.safe_dump(before)) + calls = [] + original = utils.atomic_roundtrip_yaml_update_multi + def write(target, updates, **kwargs): + calls.append(dict(updates)) + assert updates["model.default"] == "new" + assert updates["agent.reasoning_effort"] == "ultra" + if write_fails: + raise OSError("synthetic disk failure") + original(target, updates, **kwargs) + monkeypatch.setattr(utils, "atomic_roundtrip_yaml_update_multi", write) + result = ModelSwitchResult(success=True, new_model="new", target_provider="nous") + + mixin._commit_model_switch(cli, result, persist_global=True, reasoning_effort="ultra") + + assert len(calls) == 1 + assert cli.reasoning_config == {"enabled": True, "effort": "ultra"} + saved = yaml.safe_load(path.read_text()) + if write_fails: + assert saved == before + else: + assert saved["model"]["default"] == "new" + assert saved["agent"]["reasoning_effort"] == "ultra" + diff --git a/tests/hermes_cli/test_same_relay_key_routing.py b/tests/hermes_cli/test_same_relay_key_routing.py new file mode 100644 index 0000000000000..98b68c6b358d3 --- /dev/null +++ b/tests/hermes_cli/test_same_relay_key_routing.py @@ -0,0 +1,222 @@ +"""Same relay groups keep their credentials through selection and restoration. + +Everything here is synthetic and offline; config and credential-store reads are +replaced at their I/O seams, while catalog, switch and runtime resolution are real. +""" +from copy import deepcopy +from types import SimpleNamespace + +import pytest + +from hermes_cli import runtime_provider as rp +from hermes_cli.config import get_compatible_custom_providers +from hermes_cli.model_switch import list_authenticated_providers, switch_model +from hermes_cli.providers import resolve_provider_full + + +@pytest.fixture +def relay(monkeypatch): + cfg = {"model": {"provider": "custom:relay-2", "default": "shared-model"}, + "custom_providers": [ + {"name": "Relay", "base_url": "https://relay.example/v1", "key_env": "RELAY_KEY_A", "model": "shared-model", "discover_models": False}, + {"name": "Relay", "base_url": "https://relay.example/v1", "key_env": "RELAY_KEY_B", "model": "shared-model", "discover_models": False}, + ]} + monkeypatch.setattr(rp, "load_config", lambda: deepcopy(cfg)) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: deepcopy(cfg)) + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: deepcopy(cfg)) + monkeypatch.setattr(rp, "_get_model_config", lambda: deepcopy(cfg["model"])) + monkeypatch.setattr("agent.credential_pool._load_config_safe", lambda: deepcopy(cfg)) + monkeypatch.setattr(rp, "load_pool", lambda *_a, **_kw: SimpleNamespace(has_credentials=lambda: False)) + monkeypatch.setenv("RELAY_KEY_A", "synthetic-key-a") + monkeypatch.setenv("RELAY_KEY_B", "synthetic-key-b") + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {}) + monkeypatch.setattr("hermes_cli.model_switch_providers._build_curated_lists", lambda *a, **k: {}) + monkeypatch.setattr("hermes_cli.models_validate.validate_requested_model", lambda **kw: { + "accepted": True, "persist": True, "recognized": True, "message": None}) + return cfg + + +def _catalog(cfg): + return [r for r in list_authenticated_providers( + current_provider="custom:relay-2", user_providers=cfg.get("providers", {}), + custom_providers=get_compatible_custom_providers(cfg), probe_custom_providers=False, + ) if r.get("is_user_defined")] + + +@pytest.mark.parametrize("kind", ["key_env", "inline"]) +def test_second_group_runtime_uses_its_own_key(relay, kind): + if kind == "inline": + for i, entry in enumerate(relay["custom_providers"]): + entry.pop("key_env") + entry["api_key"] = ["synthetic-key-a", "synthetic-key-b"][i] + rows = _catalog(relay) + assert {r["slug"] for r in rows} == {"custom:relay", "custom:relay-2"} + selected = rp.resolve_runtime_provider(requested="custom:relay-2", target_model="shared-model") + assert selected["api_key"] == "synthetic-key-b" + result = switch_model("shared-model", current_model="shared-model", current_provider="custom:relay", + explicit_provider="custom:relay-2", user_providers={}, + custom_providers=get_compatible_custom_providers(relay)) + assert result.success, result.error_message + assert result.target_provider == "custom:relay-2" + assert result.api_key == "synthetic-key-b" + identity = rp.canonical_custom_identity(base_url=result.base_url, + config_provider=result.target_provider, model=result.new_model) + assert identity == "custom:relay-2" + assert rp.resolve_runtime_provider(requested=identity)["api_key"] == "synthetic-key-b" + + +def test_second_group_does_not_reuse_first_pool(relay, monkeypatch): + calls = [] + def load_pool(name): + calls.append(name) + return SimpleNamespace(has_credentials=lambda: name == "custom:relay", + select=lambda: SimpleNamespace(api_key="synthetic-key-a")) + monkeypatch.setattr(rp, "load_pool", load_pool) + monkeypatch.setattr(rp, "_pool_entry_api_key", lambda entry: entry.api_key) + selected = rp.resolve_runtime_provider(requested="custom:relay-2") + assert selected["api_key"] == "synthetic-key-b" + assert "custom:relay" not in calls + + +def test_group_slug_matches_after_multiple_models_in_first_group(relay): + duplicate = deepcopy(relay["custom_providers"][0]) + duplicate["model"] = "other-model" + relay["custom_providers"].insert(1, duplicate) + rows = _catalog(relay) + assert {r["slug"] for r in rows} == {"custom:relay", "custom:relay-2"} + second = resolve_provider_full("custom:relay-2", {}, get_compatible_custom_providers(relay)) + assert second is not None + assert second.api_key_env_vars == ("RELAY_KEY_B",) + assert rp.resolve_runtime_provider(requested="custom:relay-2")["api_key"] == "synthetic-key-b" + + +def test_environment_names_are_case_sensitive(relay): + relay["custom_providers"][1]["key_env"] = "relay_key_a" + assert len(get_compatible_custom_providers(relay)) == 2 + + +def test_same_address_named_and_legacy_groups_both_visible(relay): + second = relay["custom_providers"].pop() + relay["providers"] = {"relay-b": {"name": "Relay", "api": second["base_url"], + "key_env": "RELAY_KEY_B", "models": {"model-b": {}}, + "discover_models": False}} + rows = _catalog(relay) + assert len(rows) == 2 + assert {r["slug"] for r in rows} == {"custom:relay", "relay-b"} + + +def test_literal_suffix_name_does_not_collide_with_generated_slug(relay): + relay["custom_providers"].append({"name": "Relay-2", "base_url": "https://relay.example/v1", + "api_key": "synthetic-key-c", "model": "model-c", "discover_models": False}) + rows = _catalog(relay) + assert len(rows) == 3 + assert len({r["slug"] for r in rows}) == 3 + selected_keys = {rp.resolve_runtime_provider(requested=r["slug"])["api_key"] for r in rows} + assert selected_keys == {"synthetic-key-a", "synthetic-key-b", "synthetic-key-c"} + + +@pytest.mark.parametrize("kind", ["inline", "key_env"]) +def test_persisted_route_and_real_pool_stay_on_second_group(tmp_path, monkeypatch, kind): + import hermes_yaml as yaml + from agent.credential_pool import load_pool, credential_pool_matches_provider, resolve_runtime_pool_key + from hermes_cli.model_switch import persist_model_selection + from hermes_cli.config import load_config + + home = tmp_path / "relay-home" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr("hermes_cli.models_validate.validate_requested_model", lambda **kw: { + "accepted": True, "persist": True, "recognized": True, "message": None}) + rows = [] + for suffix in ("A", "B"): + row = {"name": "Relay", "base_url": "https://relay.example/v1", "model": "shared-model", + "discover_models": False} + if kind == "inline": + row["api_key"] = f"synthetic-{suffix}" + else: + row["key_env"] = f"RELAY_KEY_{suffix}" + monkeypatch.setenv(row["key_env"], f"synthetic-{suffix}") + rows.append(row) + cfg = {"model": {"provider": "custom:relay", "default": "shared-model"}, "custom_providers": rows} + path = home / "config.yaml" + path.write_text(yaml.safe_dump(cfg)) + result = switch_model("shared-model", current_model="shared-model", current_provider="custom:relay", + explicit_provider="custom:relay-2", custom_providers=get_compatible_custom_providers(load_config())) + assert result.success, result.error_message + assert result.api_key == "synthetic-B" + persist_model_selection(result, path) + saved = load_config() + assert saved["model"]["provider"] == "custom:relay-2" + assert rp.resolve_runtime_provider(requested=saved["model"]["provider"])["api_key"] == "synthetic-B" + pool = load_pool("custom:relay-2") + if kind == "inline": + assert pool.has_credentials() + selected = pool.select() + assert selected is not None + assert selected.access_token == "synthetic-B" + assert credential_pool_matches_provider(pool, "custom:relay-2", base_url=rows[1]["base_url"]) + assert not credential_pool_matches_provider("custom:relay", "custom:relay-2", base_url=rows[1]["base_url"]) + assert resolve_runtime_pool_key("custom:relay-2", rows[1]["base_url"]) == "custom:relay-2" + + +def test_discovered_catalog_writes_only_matching_key_group(relay, monkeypatch): + from hermes_cli.custom_provider_identity import credential_identity + from hermes_cli.model_switch_providers import _save_discovered_models_to_config + saved = [] + monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.append(deepcopy(cfg))) + _save_discovered_models_to_config(relay["custom_providers"][1]["base_url"], ["second-only"], + credential_identity=credential_identity(relay["custom_providers"][1])) + assert len(saved) == 1 + first, second = saved[0]["custom_providers"] + assert "models" not in first + assert list(second["models"]) == ["second-only"] + + +@pytest.mark.parametrize("kind", ["inline", "key_env", "template"]) +def test_cli_wizard_keeps_both_groups_and_updates_selected_only(tmp_path, monkeypatch, kind): + import hermes_yaml as yaml + from hermes_cli.config import load_config + from hermes_cli.main_provider_setup import _named_custom_provider_map + from hermes_cli.model_setup_flows_custom import _model_flow_named_custom + home = tmp_path / "wizard-home" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", lambda *a, **k: 1) + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + rows = [] + for suffix in ("A", "B"): + row = {"name": "Relay", "base_url": "https://relay.example/v1", "model": "old-model", + "models": ["old-model", "new-model"], "discover_models": False} + monkeypatch.setenv(f"RELAY_KEY_{suffix}", f"synthetic-{suffix}") + if kind == "key_env": + row["key_env"] = f"RELAY_KEY_{suffix}" + else: + row["api_key"] = f"${{RELAY_KEY_{suffix}}}" if kind == "template" else f"synthetic-{suffix}" + rows.append(row) + path = home / "config.yaml" + path.write_text(yaml.safe_dump({"model": {"provider": "custom:relay", "default": "old-model"}, + "custom_providers": rows})) + catalog = _named_custom_provider_map(load_config()) + assert set(catalog) == {"custom:relay", "custom:relay-2"} + _model_flow_named_custom({}, catalog["custom:relay-2"]) + saved = yaml.safe_load(path.read_text()) + assert saved["custom_providers"][0] == rows[0] + assert saved["custom_providers"][1]["model"] == "new-model" + assert saved["custom_providers"][0]["model"] == "old-model" + assert saved["model"]["provider"] == "custom:relay-2" + assert rp.resolve_runtime_provider(requested=saved["model"]["provider"])["api_key"] == "synthetic-B" + + +def test_saving_new_key_on_same_url_creates_second_group(tmp_path, monkeypatch): + import hermes_yaml as yaml + from hermes_cli.main_provider_setup import _save_custom_provider + home = tmp_path / "new-group" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + _save_custom_provider("https://relay.example/v1", "synthetic-A", "a-model", name="Relay") + _save_custom_provider("https://relay.example/v1", "synthetic-B", "b-model", name="Relay") + saved = yaml.safe_load((home / "config.yaml").read_text())["custom_providers"] + assert [(entry["api_key"], entry["model"]) for entry in saved] == [ + ("synthetic-A", "a-model"), ("synthetic-B", "b-model")] diff --git a/tests/tui_gateway/test_tui_gateway_server.py b/tests/tui_gateway/test_tui_gateway_server.py index 2ae67451a2ab2..4ea2cacebbf13 100644 --- a/tests/tui_gateway/test_tui_gateway_server.py +++ b/tests/tui_gateway/test_tui_gateway_server.py @@ -9847,9 +9847,10 @@ def _switch_model(**kwargs): monkeypatch.setattr("hermes_cli.model_switch.switch_model", _switch_model) monkeypatch.setattr(server, "_restart_slash_worker", lambda sid, session: None) monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) - # persist_model_selection uses targeted per-key writes (#48305) so it - # preserves sibling model.* keys instead of rewriting the whole block. - monkeypatch.setattr("utils.atomic_roundtrip_yaml_update", lambda path, key, value: saved_values.__setitem__(key, value)) + # persist_model_selection applies targeted dotted-key updates (#48305 intent) as ONE + # read-modify-write, so sibling model.* keys and unrelated sections survive the save. + monkeypatch.setattr("utils.atomic_roundtrip_yaml_update_multi", + lambda path, updates, **kw: saved_values.update(updates)) resp = server.handle_request( { diff --git a/utils.py b/utils.py index beeb86682000a..9109f564e0571 100644 --- a/utils.py +++ b/utils.py @@ -432,26 +432,24 @@ def _write(f) -> None: _atomic_write(path, _write, prefix=f".{path.stem}_", mode=_preserve_file_mode(path)) -def atomic_roundtrip_yaml_update(path: Union[str, Path], key_path: str, value: Any) -> None: - """Update one dotted YAML key while preserving comments, ordering, quoting and Unicode. - - Narrower than :func:`atomic_yaml_write` on purpose: for user-edited config files where a - single setting mutation must not disturb the rest. Still writes via temp file + atomic replace. - ``value=None`` removes the key (a ``key: null`` leftover reads as absent everywhere but - litters the file and diverges from whole-document writers that drop the key). - """ +# One in-process lock over every read-modify-write of a YAML config through the round-trip +# primitives below. Without it, two writers can each read, pause, and then dump their own +# snapshot last — e.g. a /model --global save reverting an unrelated /fast or /reasoning key +# (sibling of the #100314 class). Writers that change only their own dotted keys re-read under +# this lock immediately before the one dump, so keys another writer added are merged, never +# deleted by a stale whole-document snapshot. +_CONFIG_RW_LOCK = threading.RLock() + + +def _apply_dotted_update(config: Any, key_path: str, value: Any) -> None: + """Set one dotted key on a round-trip document (``None`` removes it), creating intermediate + mappings on the way. Honors escaped dots and prefers existing literal dotted keys (model IDs + like ``glm-5.3``) over blind splitting — same navigation as ``hermes config set``'s + ``_set_nested``; otherwise /model + TUI persistence wrote ``glm-5: {'3': ...}`` phantom + siblings. See #91607.""" from ruamel.yaml.comments import CommentedMap - # Honor escaped dots and prefer existing literal dotted keys (model IDs like ``glm-5.3``) over - # blind splitting — same navigation as ``hermes config set``'s ``_set_nested``; otherwise - # /model + TUI persistence wrote ``glm-5: {'3': ...}`` phantom siblings. - # See #91607. from hermes_cli.config import _greedy_literal_match, _split_key_path - path = Path(path) - from hermes_constants import mkdir_under_hermes_home - - mkdir_under_hermes_home(path.parent) - yaml_rt, config = _roundtrip_load(path) current = config keys = _split_key_path(key_path) i = 0 @@ -472,7 +470,50 @@ def atomic_roundtrip_yaml_update(path: Union[str, Path], key_path: str, value: A current[seg] = next_value current = next_value i += consumed - _roundtrip_dump(path, yaml_rt, config) + + +def atomic_roundtrip_yaml_update(path: Union[str, Path], key_path: str, value: Any) -> None: + """Update one dotted YAML key while preserving comments, ordering, quoting and Unicode. + + Narrower than :func:`atomic_yaml_write` on purpose: for user-edited config files where a + single setting mutation must not disturb the rest. Still writes via temp file + atomic replace. + ``value=None`` removes the key (a ``key: null`` leftover reads as absent everywhere but + litters the file and diverges from whole-document writers that drop the key). The read and + the write share ``_CONFIG_RW_LOCK``, so no other in-process writer lands between them. + """ + path = Path(path) + from hermes_constants import mkdir_under_hermes_home + + mkdir_under_hermes_home(path.parent) + with _CONFIG_RW_LOCK: + yaml_rt, config = _roundtrip_load(path) + _apply_dotted_update(config, key_path, value) + _roundtrip_dump(path, yaml_rt, config) + + +def atomic_roundtrip_yaml_update_multi(path: Union[str, Path], updates: "dict[str, Any]") -> None: + """Apply several dotted-key updates (``None`` = remove) in ONE read-modify-write. + + Same comment-preserving round trip and the same fail-closed readability gate as + :func:`atomic_roundtrip_yaml_save`, and the same only-your-keys scope guarantee as + :func:`atomic_roundtrip_yaml_update` — extended so a paired write (model + reasoning effort) + stays a single atomic save. The document is re-read under ``_CONFIG_RW_LOCK`` immediately + before the one dump, so keys another writer added after the caller's own read (a /fast or + /reasoning --global save) are preserved instead of being reverted or deleted by a stale + whole-document snapshot. + """ + from hermes_cli.config import require_readable_config_before_write + + path = Path(path) + from hermes_constants import mkdir_under_hermes_home + + mkdir_under_hermes_home(path.parent) + with _CONFIG_RW_LOCK: + require_readable_config_before_write(path) + yaml_rt, config = _roundtrip_load(path) + for key_path, value in updates.items(): + _apply_dotted_update(config, key_path, value) + _roundtrip_dump(path, yaml_rt, config) # ruamel's round-trip dumper resolves plain scalars under YAML 1.2, where only true/false/null are