From dd7648d56c52d7519878f9ccbfcc5867221b5fcf Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 23 May 2026 04:08:13 -0700 Subject: [PATCH 1/6] feat(runtime): wire runner route selection harness --- api/routes.py | 80 ++++++++++++++++----- docs/rfcs/hermes-run-adapter-contract.md | 66 +++++++++++++++-- tests/test_runtime_adapter_seam.py | 90 +++++++++++++++++++++--- 3 files changed, 202 insertions(+), 34 deletions(-) diff --git a/api/routes.py b/api/routes.py index b6ee042c..64fa3d9e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8463,6 +8463,41 @@ def _start_chat_stream_for_session( return response +def _runtime_runner_client_factory(): + """Return the runner-local client when a supervised backend exists. + + Slice 4d wires the `/api/chat/start` selection point without silently falling + back to the legacy in-process runtime when `runner-local` is explicitly + requested. The supervised runner backend itself is intentionally not created + in this helper yet; a later slice can replace this factory with the concrete + client while keeping the route contract stable. + """ + raise NotImplementedError("runner-local chat backend is not configured") + + +def _chat_start_response_from_run_start(result): + """Expose only the legacy browser-facing chat-start response fields.""" + payload = dict(getattr(result, "payload", {}) or {}) + response = {} + for key in ( + "stream_id", + "session_id", + "pending_started_at", + "turn_id", + "title", + "effective_model", + "effective_model_provider", + "error", + "active_stream_id", + "_status", + ): + if key in payload: + response[key] = payload[key] + response.setdefault("stream_id", result.stream_id) + response.setdefault("session_id", result.session_id) + return response + + def _runtime_adapter_goal_action(goal_args: str) -> str: """Return the bounded RuntimeAdapter goal action for WebUI /goal args.""" action = str(goal_args or "").strip().lower() @@ -8672,10 +8707,12 @@ def _handle_chat_start(handler, body, diag=None): from api.runtime_adapter import ( LegacyJournalRuntimeAdapter, StartRunRequest, + build_runtime_adapter, runtime_adapter_enabled, + runtime_adapter_runner_enabled, ) - if runtime_adapter_enabled(): + if runtime_adapter_enabled() or runtime_adapter_runner_enabled(): def _legacy_start_run(request: StartRunRequest) -> dict: return _start_chat_stream_for_session( s, @@ -8688,23 +8725,32 @@ def _handle_chat_start(handler, body, diag=None): diag=diag, ) - adapter = LegacyJournalRuntimeAdapter(start_run_delegate=_legacy_start_run) - result = adapter.start_run( - StartRunRequest( - session_id=s.session_id, - message=msg, - attachments=attachments, - workspace=workspace, - profile=getattr(s, "profile", None), - provider=model_provider, - model=model, - source="webui", - metadata={"route": "/api/chat/start"}, + def _legacy_adapter_factory(): + return LegacyJournalRuntimeAdapter(start_run_delegate=_legacy_start_run) + + try: + adapter = build_runtime_adapter( + legacy_adapter_factory=_legacy_adapter_factory, + runner_client_factory=_runtime_runner_client_factory, ) - ) - response = dict(result.payload) - response.setdefault("stream_id", result.stream_id) - response.setdefault("session_id", result.session_id) + if adapter is None: + raise NotImplementedError("runtime adapter selection returned no adapter") + result = adapter.start_run( + StartRunRequest( + session_id=s.session_id, + message=msg, + attachments=attachments, + workspace=workspace, + profile=getattr(s, "profile", None), + provider=model_provider, + model=model, + source="webui", + metadata={"route": "/api/chat/start"}, + ) + ) + except NotImplementedError as exc: + return j(handler, {"error": str(exc)}, status=501) + response = _chat_start_response_from_run_start(result) else: response = _start_chat_stream_for_session( s, diff --git a/docs/rfcs/hermes-run-adapter-contract.md b/docs/rfcs/hermes-run-adapter-contract.md index 9732044e..71ef4da2 100644 --- a/docs/rfcs/hermes-run-adapter-contract.md +++ b/docs/rfcs/hermes-run-adapter-contract.md @@ -4,7 +4,7 @@ - **Author:** @Michaelyklam - **Updated by:** @franksong2702 - **Created:** 2026-05-11 -- **Revised:** 2026-05-21 +- **Revised:** 2026-05-23 - **Tracking issue:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925) ## Credit and Scope @@ -52,7 +52,7 @@ The immediate goal is not to build a sidecar. The immediate goal is to define th browser contract, classify current runtime state, and gate the first reversible journal slice. -## Current Gate State — 2026-05-21 +## Current Gate State — 2026-05-23 Slice 1 is now past the first active validation gate: @@ -104,11 +104,14 @@ adapter-seam work: `runner-local` adapter selection point and `build_runtime_adapter(...)` factory wiring around an injected runner client. Live browser chat routes still stay on the legacy backend, and no supervised runner process exists yet. -- The next implementation gate is a supervised/local runner backend proposal and - route-selection harness. It must stay default-off, keep legacy fallback intact, - pass explicit profile/workspace/model payloads instead of mutating WebUI - process globals, and avoid recreating `STREAMS` / `CANCEL_FLAGS` / approval - queues / clarify queues under new names. +- #2744 shipped the Slice 4d supervised runner route gate in v0.51.108. +- The next implementation slice is a default-off runner route-selection harness + for `/api/chat/start`. It should only engage when `runner-local` is explicitly + selected, return a bounded not-configured error until a supervised runner + client exists, keep `legacy-direct` / `legacy-journal` fallback intact, pass + explicit profile/workspace/model payloads instead of mutating WebUI process + globals, and avoid recreating `STREAMS` / `CANCEL_FLAGS` / approval queues / + clarify queues under new names. The next gate is runner-backend plumbing, not queue implementation by default. Queue / continue routing should only move before Slice 4 if a future @@ -843,6 +846,10 @@ Non-goals for Slice 4c: #### Slice 4d: Supervised runner backend route gate +Status as of 2026-05-23: shipped in v0.51.108 via #2744. The gate remains a +docs/test contract: it defines the default-off route-selection requirements but +does not itself route live chat to a runner backend. + After `runner-local` selection exists, the next reviewable gate should define the first supervised/local runner backend and the route-selection harness before live browser chat can use it. This is still a contract/test slice first: no default-on @@ -896,6 +903,51 @@ Non-goals for Slice 4d: - no broad UI/product surface migration; WebUI remains the rich workbench while only execution ownership moves. +#### Slice 4e: Default-off runner chat-start route-selection harness + +The first implementation after the Slice 4d gate should wire the +`/api/chat/start` selection point to the existing `RuntimeAdapter` factory +without adding a supervised runner process yet. The harness must make the +selection behavior explicit: `legacy-direct` stays default, `legacy-journal` +continues to delegate to the legacy in-process stream path, and `runner-local` +does not silently fall back to legacy when no runner client is configured. + +Scope: + +- route `/api/chat/start` through `build_runtime_adapter(...)` when an adapter + mode is explicitly selected; +- keep the successful browser response whitelisted to legacy-compatible fields + such as `stream_id`, `session_id`, `pending_started_at`, `turn_id`, `title`, + and effective model/provider metadata; +- return a bounded not-configured error for `runner-local` until a supervised + runner client/backend lands; +- pass the existing explicit `StartRunRequest` payload fields across the seam. + +Acceptance tests for Slice 4e: + +1. **Default remains legacy-direct.** With no adapter env var, `/api/chat/start` + keeps using `_start_chat_stream_for_session(...)` directly. +2. **Legacy-journal remains behavior-preserving.** The flagged legacy adapter + still delegates to the same stream-start helper and preserves the public + response shape. +3. **Runner-local does not fallback silently.** If `runner-local` is selected but + no runner client exists, the route returns a bounded error instead of starting + a WebUI-owned legacy run behind the operator's back. +4. **No adapter-internal response drift.** `run_id`, `status`, and + `active_controls` remain internal until a later contract explicitly exposes + them. +5. **No runtime-surrogate globals.** The harness does not add runner-owned stream, + cancel, approval, clarify, cached-agent, goal, or queue maps to the main WebUI + process. + +Non-goals for Slice 4e: + +- no supervised runner process yet; +- no default-on runner mode; +- no execution-survives-WebUI-restart claim for production chat turns; +- no removal of `legacy-direct` or `legacy-journal`; +- no server-side queue endpoint or queue scheduler just for adapter symmetry. + ## First Meaningful Success Criteria The first meaningful milestones are deliberately split. diff --git a/tests/test_runtime_adapter_seam.py b/tests/test_runtime_adapter_seam.py index 980a7b67..99de8d98 100644 --- a/tests/test_runtime_adapter_seam.py +++ b/tests/test_runtime_adapter_seam.py @@ -409,11 +409,31 @@ def test_chat_start_route_selects_adapter_only_when_flag_enabled(): start_body = src[start_idx:src.index("def _resolve_chat_workspace_with_recovery", start_idx)] assert "runtime_adapter_enabled()" in start_body + assert "runtime_adapter_runner_enabled()" in start_body + assert "build_runtime_adapter(" in start_body + assert "legacy_adapter_factory=_legacy_adapter_factory" in start_body + assert "runner_client_factory=_runtime_runner_client_factory" in start_body assert "LegacyJournalRuntimeAdapter" in start_body assert "_start_chat_stream_for_session(" in start_body assert "HERMES_WEBUI_RUNTIME_ADAPTER" not in start_body, "route should use runtime_adapter_enabled(), not inline env checks" +def test_runner_local_chat_start_selection_does_not_fallback_to_legacy(): + routes = importlib.import_module("api.routes") + src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8") + start_idx = src.index("def _handle_chat_start") + start_body = src[start_idx:src.index("def _resolve_chat_workspace_with_recovery", start_idx)] + + flag_branch = "if runtime_adapter_enabled() or runtime_adapter_runner_enabled():" + assert flag_branch in start_body + assert "except NotImplementedError as exc:" in start_body + assert 'return j(handler, {"error": str(exc)}, status=501)' in start_body + assert "runner-local chat backend is not configured" in src + adapter_branch = start_body[start_body.index(flag_branch):start_body.index("else:", start_body.index(flag_branch))] + assert "_start_chat_stream_for_session(" in adapter_branch, "legacy-journal delegate should still call the legacy path" + assert "runtime_adapter_runner_enabled()" in adapter_branch + + def test_chat_start_adapter_path_preserves_legacy_response_shape(): """The RuntimeAdapter seam must be invisible to /api/chat/start callers. @@ -422,17 +442,53 @@ def test_chat_start_adapter_path_preserves_legacy_response_shape(): """ routes = importlib.import_module("api.routes") src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8") - start_idx = src.index("def _handle_chat_start") - start_body = src[start_idx:src.index("def _resolve_chat_workspace_with_recovery", start_idx)] - branch_start = start_body.index("if runtime_adapter_enabled():") - branch_end = start_body.index("else:", branch_start) - adapter_branch = start_body[branch_start:branch_end] + helper_idx = src.index("def _chat_start_response_from_run_start") + helper_body = src[helper_idx:src.index("def _runtime_adapter_goal_action", helper_idx)] - assert 'response.setdefault("stream_id", result.stream_id)' in adapter_branch - assert 'response.setdefault("session_id", result.session_id)' in adapter_branch - assert 'response.setdefault("run_id", result.run_id)' not in adapter_branch - assert 'response.setdefault("status", result.status)' not in adapter_branch - assert 'response.setdefault("active_controls", result.active_controls)' not in adapter_branch + assert '"stream_id",' in helper_body + assert '"session_id",' in helper_body + assert 'response.setdefault("stream_id", result.stream_id)' in helper_body + assert 'response.setdefault("session_id", result.session_id)' in helper_body + assert '"run_id",' not in helper_body + assert '"status",' not in helper_body + assert '"active_controls",' not in helper_body + + +def test_chat_start_response_from_run_start_filters_adapter_internal_fields(): + routes = importlib.import_module("api.routes") + runtime = importlib.import_module("api.runtime_adapter") + + response = routes._chat_start_response_from_run_start( + runtime.RunStartResult( + run_id="runner-internal-1", + session_id="s1", + stream_id="runner-stream-1", + status="running", + active_controls=["cancel"], + payload={ + "stream_id": "runner-stream-1", + "session_id": "s1", + "pending_started_at": 123.0, + "turn_id": "turn-1", + "title": "Demo", + "effective_model": "gpt-5.5", + "effective_model_provider": "openai-codex", + "run_id": "runner-internal-1", + "status": "running", + "active_controls": ["cancel"], + }, + ) + ) + + assert response == { + "stream_id": "runner-stream-1", + "session_id": "s1", + "pending_started_at": 123.0, + "turn_id": "turn-1", + "title": "Demo", + "effective_model": "gpt-5.5", + "effective_model_provider": "openai-codex", + } def test_rfc_distinguishes_goal_routing_from_queue_route_staging(): @@ -485,6 +541,7 @@ def test_rfc_defines_slice4d_supervised_runner_route_gate(): rfc = (routes.Path(__file__).parent.parent / "docs" / "rfcs" / "hermes-run-adapter-contract.md").read_text(encoding="utf-8") assert "#### Slice 4d: Supervised runner backend route gate" in rfc + assert "Status as of 2026-05-23: shipped in v0.51.108 via #2744" in rfc assert "After `runner-local` selection exists" in rfc assert "route-selection harness before live\nbrowser chat can use it" in rfc assert "Route remains default-off" in rfc @@ -496,6 +553,19 @@ def test_rfc_defines_slice4d_supervised_runner_route_gate(): assert "WebUI remains the rich workbench while\n only execution ownership moves" in rfc +def test_rfc_defines_slice4e_runner_chat_start_route_selection_harness(): + routes = importlib.import_module("api.routes") + rfc = (routes.Path(__file__).parent.parent / "docs" / "rfcs" / "hermes-run-adapter-contract.md").read_text(encoding="utf-8") + + assert "#### Slice 4e: Default-off runner chat-start route-selection harness" in rfc + assert "route `/api/chat/start` through `build_runtime_adapter(...)`" in rfc + assert "`legacy-direct` stays default" in rfc + assert "`legacy-journal`\ncontinues to delegate to the legacy in-process stream path" in rfc + assert "`runner-local`\ndoes not silently fall back to legacy" in rfc + assert "return a bounded not-configured error for `runner-local`" in rfc + assert "`run_id`, `status`, and\n `active_controls` remain internal" in rfc + assert "no supervised runner process yet" in rfc + def test_runner_runtime_adapter_passes_explicit_start_payload_without_env_mutation(monkeypatch): runtime = importlib.import_module("api.runtime_adapter") captured = [] From 598fd4ff83a7c8dde920e7710878a0c5eb6d493f Mon Sep 17 00:00:00 2001 From: Qi Date: Sun, 24 May 2026 05:03:35 +0000 Subject: [PATCH 2/6] perf(http): enable HTTP/1.1 keep-alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable HTTP/1.1 on the WebUI server so browsers can reuse TCP connections across normal API/static requests. Tighten response framing by adding Content-Length to short manual responses and marking SSE/streaming responses as Connection: close, keeping HTTP/1.1 message boundaries unambiguous. Verified: - python3 -m py_compile server.py api/auth.py api/routes.py api/kanban_bridge.py - pytest tests/test_auth_*.py tests/test_*sse*.py tests/test_pr1350_*.py tests/test_pr1355_sse_handler_no_deadlock.py tests/test_kanban_bridge.py tests/test_logs_ui_static.py tests/test_onboarding_static.py tests/test_regressions.py tests/test_1038_pwa_auth_redirect.py tests/test_issue1623_sse_heartbeat_alignment.py → 239 passed, 1 skipped --- api/auth.py | 5 ++++- api/kanban_bridge.py | 2 +- api/routes.py | 27 +++++++++++++++++---------- server.py | 7 +++++++ 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/api/auth.py b/api/auth.py index df5f0e4c..5a49516f 100644 --- a/api/auth.py +++ b/api/auth.py @@ -435,10 +435,12 @@ def check_auth(handler, parsed) -> bool: return True # Not authorized if parsed.path.startswith('/api/'): + body = b'{"error":"Authentication required"}' handler.send_response(401) handler.send_header('Content-Type', 'application/json') + handler.send_header('Content-Length', str(len(body))) handler.end_headers() - handler.wfile.write(b'{"error":"Authentication required"}') + handler.wfile.write(body) else: handler.send_response(302) # Pass the original path as ?next= so login.js redirects back after auth. @@ -468,6 +470,7 @@ def check_auth(handler, parsed) -> bool: # `?`, `&`, `=`) gets percent-encoded. _next = _urlparse.quote(_path_with_query, safe='/') handler.send_header('Location', 'login?next=' + _next) + handler.send_header('Content-Length', '0') handler.end_headers() return False diff --git a/api/kanban_bridge.py b/api/kanban_bridge.py index 63bef9cd..f0d5d261 100644 --- a/api/kanban_bridge.py +++ b/api/kanban_bridge.py @@ -1022,7 +1022,7 @@ def _handle_events_sse_stream(handler, parsed): handler.send_header("Content-Type", "text/event-stream; charset=utf-8") handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") - handler.send_header("Connection", "keep-alive") + handler.send_header("Connection", "close") handler.end_headers() # Send an initial frame so the client knows the connection is open diff --git a/api/routes.py b/api/routes.py index 64fa3d9e..fe0ddd0e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6216,13 +6216,15 @@ def handle_post(handler, parsed) -> bool: _record_login_attempt(client_ip) return bad(handler, "Invalid password", 401) cookie_val = create_session() + body = json.dumps({"ok": True}).encode() handler.send_response(200) handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) handler.send_header("Cache-Control", "no-store") _security_headers(handler) set_auth_cookie(handler, cookie_val) handler.end_headers() - handler.wfile.write(json.dumps({"ok": True}).encode()) + handler.wfile.write(body) return True if parsed.path == "/api/auth/logout": @@ -6231,13 +6233,15 @@ def handle_post(handler, parsed) -> bool: cookie_val = parse_cookie(handler) if cookie_val: invalidate_session(cookie_val) + body = json.dumps({"ok": True}).encode() handler.send_response(200) handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) handler.send_header("Cache-Control", "no-store") _security_headers(handler) clear_auth_cookie(handler) handler.end_headers() - handler.wfile.write(json.dumps({"ok": True}).encode()) + handler.wfile.write(body) return True # ── Checkpoints / Rollback (POST) ── @@ -6577,7 +6581,7 @@ def _handle_sse_stream(handler, parsed): handler.send_header("Content-Type", "text/event-stream; charset=utf-8") handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") - handler.send_header("Connection", "keep-alive") + handler.send_header("Connection", "close") handler.end_headers() try: _replay_run_journal(handler, stream_id, _parse_run_journal_after_seq(qs)) @@ -6589,7 +6593,7 @@ def _handle_sse_stream(handler, parsed): handler.send_header("Content-Type", "text/event-stream; charset=utf-8") handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") - handler.send_header("Connection", "keep-alive") + handler.send_header("Connection", "close") handler.end_headers() try: while True: @@ -6720,7 +6724,7 @@ def _handle_terminal_output(handler, parsed): handler.send_header("Content-Type", "text/event-stream; charset=utf-8") handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") - handler.send_header("Connection", "keep-alive") + handler.send_header("Connection", "close") handler.end_headers() try: while True: @@ -6798,7 +6802,7 @@ def _handle_gateway_sse_stream(handler, parsed): handler.send_header('Content-Type', 'text/event-stream; charset=utf-8') handler.send_header('Cache-Control', 'no-cache') handler.send_header('X-Accel-Buffering', 'no') - handler.send_header('Connection', 'keep-alive') + handler.send_header('Connection', 'close') handler.end_headers() q = watcher.subscribe() @@ -6831,7 +6835,7 @@ def _handle_session_events_stream(handler): handler.send_header('Content-Type', 'text/event-stream; charset=utf-8') handler.send_header('Cache-Control', 'no-cache') handler.send_header('X-Accel-Buffering', 'no') - handler.send_header('Connection', 'keep-alive') + handler.send_header('Connection', 'close') handler.end_headers() q = subscribe_session_events() @@ -6917,6 +6921,7 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_ handler.send_response(416) handler.send_header("Content-Range", f"bytes */{file_size}") handler.send_header("Accept-Ranges", "bytes") + handler.send_header("Content-Length", "0") _security_headers(handler) handler.end_headers() return True @@ -7027,10 +7032,12 @@ def _handle_media(handler, parsed): if is_auth_enabled(): cv = parse_cookie(handler) if not (cv and verify_session(cv)): + body = b'{"error":"Authentication required"}' handler.send_response(401) handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) handler.end_headers() - handler.wfile.write(b'{"error":"Authentication required"}') + handler.wfile.write(body) return qs = parse_qs(parsed.query) @@ -7389,7 +7396,7 @@ def _handle_approval_sse_stream(handler, parsed): handler.send_header('Content-Type', 'text/event-stream; charset=utf-8') handler.send_header('Cache-Control', 'no-cache') handler.send_header('X-Accel-Buffering', 'no') - handler.send_header('Connection', 'keep-alive') + handler.send_header('Connection', 'close') handler.end_headers() from api.streaming import _sse @@ -7490,7 +7497,7 @@ def _handle_clarify_sse_stream(handler, parsed): handler.send_header('Content-Type', 'text/event-stream; charset=utf-8') handler.send_header('Cache-Control', 'no-cache') handler.send_header('X-Accel-Buffering', 'no') - handler.send_header('Connection', 'keep-alive') + handler.send_header('Connection', 'close') handler.end_headers() from api.streaming import _sse diff --git a/server.py b/server.py index e6636209..e6a2c65a 100644 --- a/server.py +++ b/server.py @@ -170,6 +170,13 @@ class QuietHTTPServer(ThreadingHTTPServer): class Handler(BaseHTTPRequestHandler): + # HTTP/1.1 enables keep-alive connection reuse — major latency win on + # high-RTT links where every saved TCP handshake is 2×RTT. Each response + # MUST declare framing (Content-Length, Transfer-Encoding: chunked, or + # Connection: close) so the client knows where the message ends. Helpers + # j()/t() emit Content-Length; SSE/streaming endpoints emit + # Connection: close because the body has no terminator. See PR notes. + protocol_version = "HTTP/1.1" timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion def setup(self): From a86b378036192fa77bf4afd6766153593cd54ff5 Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Sun, 24 May 2026 18:28:26 +0000 Subject: [PATCH 3/6] =?UTF-8?q?Stage=20405:=20PR=20#2680=20=E2=80=94=20fea?= =?UTF-8?q?t:=20add=20Auxiliary=20Models=20settings=20card=20by=20@mccxj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked via 3-way apply (rebase had failed on static/index.html conflict when applied via rebase commit chain; 3-way of the net delta against stage HEAD applied cleanly). Co-authored-by: mccxj --- api/config.py | 106 ++++++++++++ api/routes.py | 24 +++ static/i18n.js | 176 +++++++++++++++++++ static/index.html | 9 + static/panels.js | 219 ++++++++++++++++++++++++ tests/test_auxiliary_models_settings.py | 217 +++++++++++++++++++++++ 6 files changed, 751 insertions(+) create mode 100644 tests/test_auxiliary_models_settings.py diff --git a/api/config.py b/api/config.py index 293dc953..66bcb570 100644 --- a/api/config.py +++ b/api/config.py @@ -2185,6 +2185,112 @@ def set_hermes_default_model(model_id: str) -> dict: return {"ok": True, "model": persisted_model} +# ── Auxiliary model configuration ────────────────────────────────────────── + +# Canonical auxiliary task slots. Keep in sync with hermes_cli/config.py +# DEFAULT_CONFIG["auxiliary"] and hermes_cli/web_server.py _AUX_TASK_SLOTS. +AUX_TASK_SLOTS: tuple[str, ...] = ( + "vision", + "web_extract", + "compression", + "session_search", + "skills_hub", + "approval", + "mcp", + "title_generation", + "curator", +) + + +def get_auxiliary_models() -> dict: + """Return current auxiliary task assignments from config.yaml. + + Shape: + { + "tasks": [ + {"task": "vision", "provider": "auto", "model": "", "base_url": ""}, + ... + ], + "main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"}, + } + """ + reload_config() + model_cfg = cfg.get("model", {}) + if not isinstance(model_cfg, dict): + model_cfg = {} + main_provider = str(model_cfg.get("provider") or "").strip() + main_model = str(model_cfg.get("default") or model_cfg.get("name") or "").strip() + + aux_cfg = cfg.get("auxiliary", {}) + if not isinstance(aux_cfg, dict): + aux_cfg = {} + + tasks = [] + for slot in AUX_TASK_SLOTS: + entry = aux_cfg.get(slot, {}) + if not isinstance(entry, dict): + entry = {} + tasks.append({ + "task": slot, + "provider": str(entry.get("provider") or "auto").strip(), + "model": str(entry.get("model") or "").strip(), + "base_url": str(entry.get("base_url") or "").strip(), + }) + + return { + "tasks": tasks, + "main": {"provider": main_provider, "model": main_model}, + } + + +def set_auxiliary_model(task: str, provider: str, model: str) -> dict: + """Persist an auxiliary model assignment in config.yaml. + + Special case: task='__reset__' clears all auxiliary slots. + """ + config_path = _get_config_path() + with _cfg_lock: + config_data = _load_yaml_config_file(config_path) + + if task == "__reset__": + # Per-slot reset: set each slot to auto, preserving extra fields + # (timeout, extra_body, api_key, base_url, download_timeout, etc.) + aux_cfg = config_data.get("auxiliary", {}) + if not isinstance(aux_cfg, dict): + aux_cfg = {} + for slot in AUX_TASK_SLOTS: + slot_cfg = aux_cfg.get(slot, {}) + if not isinstance(slot_cfg, dict): + slot_cfg = {} + slot_cfg["provider"] = "auto" + slot_cfg["model"] = "" + aux_cfg[slot] = slot_cfg + config_data["auxiliary"] = aux_cfg + else: + aux_cfg = config_data.get("auxiliary", {}) + if not isinstance(aux_cfg, dict): + aux_cfg = {} + slot_cfg = aux_cfg.get(task, {}) + if not isinstance(slot_cfg, dict): + slot_cfg = {} + slot_cfg["provider"] = provider or "auto" + slot_cfg["model"] = model or "" + if provider and (provider.startswith("custom:") or provider == "custom"): + try: + _, _, resolved_base_url = resolve_model_provider(model) + if resolved_base_url: + slot_cfg["base_url"] = str(resolved_base_url).strip().rstrip("/") + except Exception: + pass + aux_cfg[task] = slot_cfg + config_data["auxiliary"] = aux_cfg + + _save_yaml_config_file(config_path, config_data) + + reload_config() + return {"ok": True, "task": task, "provider": provider, "model": model} + + # ── TTL cache for get_available_models() ───────────────────────────────────── _available_models_cache: dict | None = None _available_models_cache_ts: float = 0.0 diff --git a/api/routes.py b/api/routes.py index fe0ddd0e..da861a7d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3687,6 +3687,11 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/models/live": return _handle_live_models(handler, parsed) + # ── Auxiliary models (GET/POST) ── + if parsed.path == "/api/model/auxiliary": + from api.config import get_auxiliary_models + return j(handler, get_auxiliary_models()) + if parsed.path == "/api/dashboard/status": from api import dashboard_probe @@ -4857,6 +4862,25 @@ def handle_post(handler, parsed) -> bool: except RuntimeError as e: return bad(handler, str(e), 500) + # ── Auxiliary model set (POST) ── + if parsed.path == "/api/model/set": + scope = str(body.get("scope") or "").strip() + task = str(body.get("task") or "").strip() + provider = str(body.get("provider") or "auto").strip() + model = str(body.get("model") or "").strip() + if scope == "auxiliary": + from api.config import set_auxiliary_model + try: + return j(handler, set_auxiliary_model(task, provider, model)) + except Exception as exc: + return bad(handler, str(exc), status=400) + if scope == "main": + try: + return j(handler, set_hermes_default_model(model)) + except ValueError as exc: + return bad(handler, str(exc), status=400) + return bad(handler, f"unknown scope: {scope}", status=400) + # ── Providers (POST) ── if parsed.path == "/api/providers": provider_id = (body.get("provider") or "").strip().lower() diff --git a/static/i18n.js b/static/i18n.js index c1ba196f..51aa5543 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -548,6 +548,22 @@ const LOCALES = { settings_save_btn: 'Save Settings', settings_label_model: 'Default Model', settings_desc_model: 'Used for new conversations. Existing conversations keep their selected model.', + settings_label_auxiliary_models: 'Auxiliary Models', + settings_desc_auxiliary_models: 'Side-task routing for vision, compression, title generation, etc. "Auto" uses your main chat model.', + settings_btn_reset_aux_models: 'Reset all to auto', + settings_btn_apply_aux_models: 'Apply changes', + settings_aux_provider_auto: 'use main model', + settings_aux_model_auto: 'auto (use provider default)', + settings_aux_model_custom: 'Custom model…', + settings_aux_model_custom_prompt: 'Enter model ID:', + settings_aux_loading: 'Loading auxiliary models…', + settings_aux_load_failed: 'Could not load auxiliary model settings. Make sure the agent API is available.', + settings_aux_reset_confirm_title: 'Reset auxiliary models?', + settings_aux_reset_confirm_msg: 'This will set all auxiliary tasks to auto (use main model).', + settings_aux_reset_done: 'Auxiliary models reset to auto', + settings_aux_save_failed: 'Failed to save auxiliary model', + settings_aux_saved: 'Auxiliary models updated', + settings_aux_no_changes: 'No changes to apply', settings_label_send_key: 'Send Key', settings_label_theme: 'Theme', settings_label_skin: 'Skin', @@ -1791,6 +1807,22 @@ const LOCALES = { settings_save_btn: 'Salva Impostazioni', settings_label_model: 'Modello Predefinito', settings_desc_model: 'Usato per le nuove conversazioni. Le conversazioni esistenti mantengono il modello selezionato.', + settings_label_auxiliary_models: 'Modelli Ausiliari', + settings_desc_auxiliary_models: 'Routing per attività secondarie come visione, compressione, generazione titoli, ecc. "Auto" utilizza il modello chat principale.', + settings_btn_reset_aux_models: 'Ripristina tutto ad auto', + settings_btn_apply_aux_models: 'Applica modifiche', + settings_aux_provider_auto: 'usa modello principale', + settings_aux_model_auto: 'auto (usa predefinito del provider)', + settings_aux_model_custom: 'Modello personalizzato…', + settings_aux_model_custom_prompt: 'Inserisci ID modello:', + settings_aux_loading: 'Caricamento modelli ausiliari…', + settings_aux_load_failed: 'Impossibile caricare le impostazioni dei modelli ausiliari. Assicurati che l’API dell’agent sia disponibile.', + settings_aux_reset_confirm_title: 'Ripristinare modelli ausiliari?', + settings_aux_reset_confirm_msg: 'Questo imposterà tutte le attività ausiliarie ad auto (usa modello principale).', + settings_aux_reset_done: 'Modelli ausiliari ripristinati ad auto', + settings_aux_save_failed: 'Salvataggio del modello ausiliario non riuscito', + settings_aux_saved: 'Modelli ausiliari aggiornati', + settings_aux_no_changes: 'Nessuna modifica da applicare', settings_label_send_key: 'Tasto Invio', settings_label_theme: 'Tema', settings_label_skin: 'Skin', @@ -3026,6 +3058,22 @@ const LOCALES = { settings_save_btn: '設定を保存', settings_label_model: 'デフォルトモデル', settings_desc_model: '新しい会話で使用されます。既存の会話は選択済みモデルを保持します。', + settings_label_auxiliary_models: '補助モデル', + settings_desc_auxiliary_models: 'ビジョン、圧縮、タイトル生成などの補助タスクのルーティング。「自動」はメインチャットモデルを使用します。', + settings_btn_reset_aux_models: 'すべて自動にリセット', + settings_btn_apply_aux_models: '変更を適用', + settings_aux_provider_auto: 'メインモデルを使用', + settings_aux_model_auto: '自動(プロバイダーのデフォルトを使用)', + settings_aux_model_custom: 'カスタムモデル…', + settings_aux_model_custom_prompt: 'モデルIDを入力:', + settings_aux_loading: '補助モデルを読み込み中…', + settings_aux_load_failed: '補助モデル設定を読み込めませんでした。エージェントAPIが利用可能であることを確認してください。', + settings_aux_reset_confirm_title: '補助モデルをリセットしますか?', + settings_aux_reset_confirm_msg: 'すべての補助タスクを自動(メインモデルを使用)に設定します。', + settings_aux_reset_done: '補助モデルを自動にリセットしました', + settings_aux_save_failed: '補助モデルの保存に失敗しました', + settings_aux_saved: '補助モデルを更新しました', + settings_aux_no_changes: '適用する変更はありません', settings_label_send_key: '送信キー', settings_label_theme: 'テーマ', settings_label_skin: 'スキン', @@ -4077,6 +4125,22 @@ const LOCALES = { settings_save_btn: 'Сохранить настройки', settings_label_model: 'Модель по умолчанию', settings_desc_model: 'Используется для новых бесед. Существующие беседы сохраняют выбранную модель.', + settings_label_auxiliary_models: 'Вспомогательные модели', + settings_desc_auxiliary_models: 'Маршрутизация побочных задач: зрение, сжатие, генерация заголовков и т.д. «Авто» использует основную модель чата.', + settings_btn_reset_aux_models: 'Сбросить всё на авто', + settings_btn_apply_aux_models: 'Применить изменения', + settings_aux_provider_auto: 'использовать основную модель', + settings_aux_model_auto: 'авто (по умолчанию провайдера)', + settings_aux_model_custom: 'Пользовательская модель…', + settings_aux_model_custom_prompt: 'Введите ID модели:', + settings_aux_loading: 'Загрузка вспомогательных моделей…', + settings_aux_load_failed: 'Не удалось загрузить настройки вспомогательных моделей. Убедитесь, что API агента доступен.', + settings_aux_reset_confirm_title: 'Сбросить вспомогательные модели?', + settings_aux_reset_confirm_msg: 'Все вспомогательные задачи будут установлены на «авто» (использовать основную модель).', + settings_aux_reset_done: 'Вспомогательные модели сброшены на авто', + settings_aux_save_failed: 'Не удалось сохранить вспомогательную модель', + settings_aux_saved: 'Вспомогательные модели обновлены', + settings_aux_no_changes: 'Нет изменений для применения', settings_label_send_key: 'Клавиша отправки', settings_label_theme: 'Тема', settings_label_language: 'Язык', @@ -5231,6 +5295,22 @@ const LOCALES = { settings_save_btn: 'Guardar configuración', settings_label_model: 'Modelo predeterminado', settings_desc_model: 'Se usa para conversaciones nuevas. Las conversaciones existentes conservan su modelo seleccionado.', + settings_label_auxiliary_models: 'Modelos auxiliares', + settings_desc_auxiliary_models: 'Enrutamiento para tareas secundarias como visión, compresión, generación de títulos, etc. «Auto» usa el modelo de chat principal.', + settings_btn_reset_aux_models: 'Restablecer todo a auto', + settings_btn_apply_aux_models: 'Aplicar cambios', + settings_aux_provider_auto: 'usar modelo principal', + settings_aux_model_auto: 'auto (usar predeterminado del proveedor)', + settings_aux_model_custom: 'Modelo personalizado…', + settings_aux_model_custom_prompt: 'Ingrese ID del modelo:', + settings_aux_loading: 'Cargando modelos auxiliares…', + settings_aux_load_failed: 'No se pudieron cargar las configuraciones de modelos auxiliares. Asegúrese de que la API del agente esté disponible.', + settings_aux_reset_confirm_title: '¿Restablecer modelos auxiliares?', + settings_aux_reset_confirm_msg: 'Esto establecerá todas las tareas auxiliares en auto (usar modelo principal).', + settings_aux_reset_done: 'Modelos auxiliares restablecidos a auto', + settings_aux_save_failed: 'Error al guardar el modelo auxiliar', + settings_aux_saved: 'Modelos auxiliares actualizados', + settings_aux_no_changes: 'Sin cambios para aplicar', settings_label_send_key: 'Tecla de envío', settings_label_theme: 'Tema', settings_label_skin: 'Piel', @@ -6388,6 +6468,22 @@ const LOCALES = { settings_save_btn: 'Einstellungen speichern', settings_label_model: 'Standard-Modell', settings_desc_model: 'Wird für neue Chats verwendet. Bestehende Chats behalten ihr ausgewähltes Modell.', + settings_label_auxiliary_models: 'Hilfsmodelle', + settings_desc_auxiliary_models: 'Routing für Nebenaufgaben wie Vision, Komprimierung, Titelgenerierung usw. „Auto" verwendet das Haupt-Chat-Modell.', + settings_btn_reset_aux_models: 'Alle auf auto zurücksetzen', + settings_btn_apply_aux_models: 'Änderungen anwenden', + settings_aux_provider_auto: 'Hauptmodell verwenden', + settings_aux_model_auto: 'auto (Provider-Standard verwenden)', + settings_aux_model_custom: 'Benutzerdefiniertes Modell…', + settings_aux_model_custom_prompt: 'Modell-ID eingeben:', + settings_aux_loading: 'Hilfsmodelle werden geladen…', + settings_aux_load_failed: 'Hilfsmodelle-Einstellungen konnten nicht geladen werden. Stellen Sie sicher, dass die Agent-API verfügbar ist.', + settings_aux_reset_confirm_title: 'Hilfsmodelle zurücksetzen?', + settings_aux_reset_confirm_msg: 'Dies setzt alle Hilfsaufgaben auf auto (Hauptmodell verwenden).', + settings_aux_reset_done: 'Hilfsmodelle auf auto zurückgesetzt', + settings_aux_save_failed: 'Hilfsmodell konnte nicht gespeichert werden', + settings_aux_saved: 'Hilfsmodelle aktualisiert', + settings_aux_no_changes: 'Keine Änderungen anzuwenden', settings_label_send_key: 'Sende-Taste', settings_label_theme: 'Theme', settings_label_skin: 'Skin', @@ -7597,6 +7693,22 @@ const LOCALES = { settings_save_btn: '保存设置', settings_label_model: '默认模型', settings_desc_model: '用于新对话。现有对话保持各自选定的模型。', + settings_label_auxiliary_models: '辅助模型', + settings_desc_auxiliary_models: '视觉分析、上下文压缩、标题生成等辅助任务的路由。"自动"表示使用主聊天模型。', + settings_btn_reset_aux_models: '全部重置为自动', + settings_btn_apply_aux_models: '应用更改', + settings_aux_provider_auto: '使用主模型', + settings_aux_model_auto: '自动(使用提供商默认)', + settings_aux_model_custom: '自定义模型…', + settings_aux_model_custom_prompt: '输入模型 ID:', + settings_aux_loading: '正在加载辅助模型…', + settings_aux_load_failed: '无法加载辅助模型设置,请确保 Agent API 可用。', + settings_aux_reset_confirm_title: '重置辅助模型?', + settings_aux_reset_confirm_msg: '这将把所有辅助任务设置为自动(使用主模型)。', + settings_aux_reset_done: '辅助模型已重置为自动', + settings_aux_save_failed: '辅助模型保存失败', + settings_aux_saved: '辅助模型已更新', + settings_aux_no_changes: '没有需要应用的更改', settings_label_send_key: '发送快捷键', settings_label_theme: '主题', settings_label_skin: '皮肤', @@ -8783,6 +8895,22 @@ const LOCALES = { settings_save_btn: '\u5132\u5b58\u8a2d\u5b9a', settings_label_model: '\u9ed8\u8a8d\u6a21\u578b', settings_desc_model: '\u7528\u65bc\u65b0\u6703\u8a71\u3002\u73fe\u6709\u6703\u8a71\u6703\u4fdd\u7559\u5404\u81ea\u9078\u5b9a\u7684\u6a21\u578b\u3002', + settings_label_auxiliary_models: '\u8f14\u52a9\u6a21\u578b', + settings_desc_auxiliary_models: '\u8996\u89ba\u5206\u6790\u3001\u4e0a\u4e0b\u6587\u58d3\u7e2e\u3001\u6a19\u984c\u7522\u751f\u7b49\u8f14\u52a9\u4efb\u52d9\u7684\u8def\u7531\u3002\u300c\u81ea\u52d5\u300d\u8868\u793a\u4f7f\u7528\u4e3b\u804a\u5929\u6a21\u578b\u3002', + settings_btn_reset_aux_models: '\u5168\u90e8\u91cd\u7f6e\u70ba\u81ea\u52d5', + settings_btn_apply_aux_models: '\u61c9\u7528\u8b8a\u66f4', + settings_aux_provider_auto: '\u4f7f\u7528\u4e3b\u6a21\u578b', + settings_aux_model_auto: '\u81ea\u52d5\uff08\u4f7f\u7528\u63d0\u4f9b\u5546\u9810\u8a2d\uff09', + settings_aux_model_custom: '\u81ea\u8a02\u6a21\u578b\u2026', + settings_aux_model_custom_prompt: '\u8f38\u5165\u6a21\u578b ID\uff1a', + settings_aux_loading: '\u6b63\u5728\u8f09\u5165\u8f14\u52a9\u6a21\u578b\u2026', + settings_aux_load_failed: '\u7121\u6cd5\u8f09\u5165\u8f14\u52a9\u6a21\u578b\u8a2d\u5b9a\uff0c\u8acb\u78ba\u4fdd Agent API \u53ef\u7528\u3002', + settings_aux_reset_confirm_title: '\u91cd\u7f6e\u8f14\u52a9\u6a21\u578b\uff1f', + settings_aux_reset_confirm_msg: '\u9019\u5c07\u628a\u6240\u6709\u8f14\u52a9\u4efb\u52d9\u8a2d\u5b9a\u70ba\u81ea\u52d5\uff08\u4f7f\u7528\u4e3b\u6a21\u578b\uff09\u3002', + settings_aux_reset_done: '\u8f14\u52a9\u6a21\u578b\u5df2\u91cd\u7f6e\u70ba\u81ea\u52d5', + settings_aux_save_failed: '\u8f14\u52a9\u6a21\u578b\u5132\u5b58\u5931\u6557', + settings_aux_saved: '\u8f14\u52a9\u6a21\u578b\u5df2\u66f4\u65b0', + settings_aux_no_changes: '\u6c92\u6709\u9700\u8981\u61c9\u7528\u7684\u8b8a\u66f4', settings_label_send_key: '\u767c\u9001\u5feb\u6377\u9375', settings_label_theme: '\u4e3b\u984c', settings_label_skin: '佈景', @@ -10096,6 +10224,22 @@ const LOCALES = { settings_save_btn: 'Salvar Configurações', settings_label_model: 'Modelo Padrão', settings_desc_model: 'Usado para novas conversas. Conversas existentes mantêm o modelo selecionado.', + settings_label_auxiliary_models: 'Modelos auxiliares', + settings_desc_auxiliary_models: 'Roteamento para tarefas secundárias como visão, compressão, geração de títulos, etc. "Auto" usa o modelo de chat principal.', + settings_btn_reset_aux_models: 'Restaurar tudo para auto', + settings_btn_apply_aux_models: 'Aplicar alterações', + settings_aux_provider_auto: 'usar modelo principal', + settings_aux_model_auto: 'auto (usar padrão do provedor)', + settings_aux_model_custom: 'Modelo personalizado…', + settings_aux_model_custom_prompt: 'Insira o ID do modelo:', + settings_aux_loading: 'Carregando modelos auxiliares…', + settings_aux_load_failed: 'Não foi possível carregar as configurações de modelos auxiliares. Certifique-se de que a API do agente esteja disponível.', + settings_aux_reset_confirm_title: 'Restaurar modelos auxiliares?', + settings_aux_reset_confirm_msg: 'Isso definirá todas as tarefas auxiliares para auto (usar modelo principal).', + settings_aux_reset_done: 'Modelos auxiliares restaurados para auto', + settings_aux_save_failed: 'Falha ao salvar o modelo auxiliar', + settings_aux_saved: 'Modelos auxiliares atualizados', + settings_aux_no_changes: 'Nenhuma alteração para aplicar', settings_label_send_key: 'Tecla de Envio', settings_label_theme: 'Tema', settings_label_skin: 'Skin', @@ -11234,6 +11378,22 @@ const LOCALES = { settings_save_btn: '설정 저장', settings_label_model: '기본 모델', settings_desc_model: '새 대화에 사용됩니다. 기존 대화는 선택된 모델을 유지합니다.', + settings_label_auxiliary_models: '보조 모델', + settings_desc_auxiliary_models: '비전, 압축, 제목 생성 등 보조 작업 라우팅. "자동"은 기본 채팅 모델을 사용합니다.', + settings_btn_reset_aux_models: '모두 자동으로 재설정', + settings_btn_apply_aux_models: '변경 사항 적용', + settings_aux_provider_auto: '기본 모델 사용', + settings_aux_model_auto: '자동 (제공자 기본값 사용)', + settings_aux_model_custom: '사용자 정의 모델…', + settings_aux_model_custom_prompt: '모델 ID 입력:', + settings_aux_loading: '보조 모델 로딩 중…', + settings_aux_load_failed: '보조 모델 설정을 로드할 수 없습니다. 에이전트 API가 사용 가능한지 확인하세요.', + settings_aux_reset_confirm_title: '보조 모델을 재설정하시겠습니까?', + settings_aux_reset_confirm_msg: '모든 보조 작업이 자동(기본 모델 사용)으로 설정됩니다.', + settings_aux_reset_done: '보조 모델이 자동으로 재설정됨', + settings_aux_save_failed: '보조 모델 저장 실패', + settings_aux_saved: '보조 모델 업데이트됨', + settings_aux_no_changes: '적용할 변경 사항 없음', settings_label_send_key: '전송 키', settings_label_theme: '테마', settings_label_skin: '스킨', @@ -12385,6 +12545,22 @@ const LOCALES = { settings_save_btn: 'Enregistrer les paramètres', settings_label_model: 'Modèle par défaut', settings_desc_model: 'Utilisé pour les nouvelles conversations. Les conversations existantes conservent leur modèle sélectionné.', + settings_label_auxiliary_models: 'Modèles auxiliaires', + settings_desc_auxiliary_models: 'Routage des tâches secondaires : vision, compression, génération de titres, etc. « Auto » utilise le modèle de chat principal.', + settings_btn_reset_aux_models: 'Tout réinitialiser à auto', + settings_btn_apply_aux_models: 'Appliquer les modifications', + settings_aux_provider_auto: 'utiliser le modèle principal', + settings_aux_model_auto: 'auto (utiliser la valeur par défaut du fournisseur)', + settings_aux_model_custom: 'Modèle personnalisé…', + settings_aux_model_custom_prompt: 'Entrez l\u2019ID du modèle :', + settings_aux_loading: 'Chargement des modèles auxiliaires…', + settings_aux_load_failed: 'Impossible de charger les paramètres des modèles auxiliaires. Vérifiez que l\u2019API de l\u2019agent est disponible.', + settings_aux_reset_confirm_title: 'Réinitialiser les modèles auxiliaires ?', + settings_aux_reset_confirm_msg: 'Cela définira toutes les tâches auxiliaires sur auto (utiliser le modèle principal).', + settings_aux_reset_done: 'Modèles auxiliaires réinitialisés à auto', + settings_aux_save_failed: 'Échec de la sauvegarde du modèle auxiliaire', + settings_aux_saved: 'Modèles auxiliaires mis à jour', + settings_aux_no_changes: 'Aucune modification à appliquer', settings_label_send_key: 'Envoyer la clé', settings_label_theme: 'Thème', settings_label_skin: 'Peau', diff --git a/static/index.html b/static/index.html index e5518943..8b4009c0 100644 --- a/static/index.html +++ b/static/index.html @@ -997,6 +997,15 @@
Used for new conversations. Existing conversations keep their selected model.
+
+ +
Side-task routing for vision, compression, title generation, etc. "Auto" uses your main chat model.
+
+
+ + +
+