diff --git a/ods/FAQ.md b/ods/FAQ.md index 4682673e56..93a05a1dab 100644 --- a/ods/FAQ.md +++ b/ods/FAQ.md @@ -485,7 +485,7 @@ SQLite databases are in Docker volumes: Access via: ```bash -docker compose exec n8n sqlite3 /home/node/.n8n/database.sqlite +docker compose exec n8n sqlite3 /tmp/.n8n/database.sqlite ``` ### Can I use OpenAI/Anthropic APIs? diff --git a/ods/bin/ods-host-agent.py b/ods/bin/ods-host-agent.py index 061aaaf1ba..7d8167d517 100755 --- a/ods/bin/ods-host-agent.py +++ b/ods/bin/ods-host-agent.py @@ -2176,6 +2176,12 @@ def _project_switchboard_agent_viability(payload: dict) -> None: structurally validated. Missing, stale, or malformed state remains unknown rather than inventing either readiness or failure for legacy installations. """ + try: + payload["modelTransactionPending"] = bool(_pixel_model_recovery_status()["pending"]) + except (OSError, ValueError, RuntimeError, KeyError, TypeError): + # An unreadable native transaction journal is not proof that the + # model transition finished. Pixel must remain unavailable for chat. + payload["modelTransactionPending"] = True remote_runtime = _active_remote_provider_pixel_runtime() if remote_runtime is not None: payload["activeAgentViable"] = True @@ -3755,6 +3761,10 @@ class _PixelModelTransactionRejected(RuntimeError): """The controller definitively refused admission without performing it.""" +class _ExternalAdoptionReceiptUnavailable(RuntimeError): + """The route committed, but its separate dashboard receipt was not written.""" + + def _pixel_model_journal_path() -> Path: return INSTALL_DIR / 'data' / 'pixel-model-transaction.json' @@ -3778,6 +3788,51 @@ def _publish_activation_route(env: dict, model_id: str, proof: dict, capabilitie ) +def _external_adoption_route_published(model_id: str, context_length: int) -> bool: + """Avoid recording a second route when a held adoption is retried.""" + if _switchboard_state is None: + return False + doc, errors = _switchboard_state.read_state(INSTALL_DIR / "data" / "model-state.json") + if errors or not isinstance(doc, dict): + raise RuntimeError("Existing model route cannot be verified") + active = doc.get("active") + if not isinstance(active, dict): + return False + backend = active.get("backend") + proof = active.get("proof") + return bool( + active.get("catalogId") == model_id + and active.get("runtimeModelId") == model_id + and active.get("contextLength") == context_length + and active.get("reconstructed") is not True + and isinstance(active.get("verifiedAt"), str) and active["verifiedAt"] + and isinstance(backend, dict) and backend.get("kind") == "lemonade" + and backend.get("nativeRoute") == model_id + and isinstance(proof, dict) and proof.get("identity") == model_id + and proof.get("completion") is True + ) + + +def _external_adoption_capabilities(model_id: str, context_length: int) -> dict[str, bool]: + """Apply the same catalog advisory as local activation when identifiable.""" + try: + candidates = [item for item in _load_model_library_records() if + _runtime_model_identity_matches( + model_id, model_id=str(item.get("id") or ""), + gguf_file=str(item.get("gguf_file") or ""), + llm_model_name=str(item.get("llm_model_name") or ""), + )] + except RuntimeError: + candidates = [] + model = candidates[0] if len(candidates) == 1 else {} + return { + "chat": True, + "tools": bool(model.get("tools")), + "vision": bool(model.get("vision")), + "agentViable": _model_agent_viable(model, context_length), + } + + def _pixel_model_config_paths() -> dict: paths = {name: INSTALL_DIR / name for name in ( '.env', 'config/llama-server/models.ini', 'config/litellm/lemonade.yaml', @@ -3939,6 +3994,47 @@ def _begin_pixel_model_transaction(config: dict): return _PixelModelTransaction(config).begin() +def _begin_or_resume_external_pixel_transaction(config: dict, target: dict): + """Resume a proved adoption without replaying an ambiguous native mutation.""" + if not config.get('PIXEL_OPENWEBUI_KEY') or not _valid_managed_pixel_runtime_contract(target): + raise RuntimeError('Managed Pixel adoption contract is unavailable') + journal = _read_pixel_model_journal() + if journal is not None and journal['phase'] != 'completed': + if journal['phase'] not in {'held', 'applying', 'applied'} or journal['target'] != target: + raise _PixelModelTransactionUncertain( + 'Another managed model transaction requires explicit recovery' + ) + transaction = _PixelModelTransaction(config) + transaction.id = journal['transactionId'] + transaction.previous = journal['previous'] + transaction.target = journal['target'] + transaction.journal = journal + if journal['phase'] == 'held': + transaction.verify_held() + else: + # A lost apply reply is never replayed. Native status must prove + # that the exact target was already applied before we can finish. + try: + status = _runtime_model_control('model-status', config=config) + except Exception as exc: + raise _PixelModelTransactionUncertain( + 'Managed model apply is unconfirmed; recovery is required' + ) from exc + if status['status'] != 'applied' or not transaction._matches(status, 'applied', target): + raise _PixelModelTransactionUncertain( + 'Managed model apply is unconfirmed; recovery is required' + ) + if journal['phase'] == 'applying': + transaction._save('applied') + return transaction + recovery = _recover_pixel_model_transaction(config) + if recovery['pending']: + raise _PixelModelTransactionUncertain('Managed model recovery is pending') + transaction = _PixelModelTransaction(config) + transaction.target = dict(target) + return transaction.begin() + + def _prove_pixel_model_contract(config: dict, contract: dict) -> bool: if 'routeFingerprint' in contract: route = _read_remote_provider_route_state_for_update() @@ -3946,6 +4042,18 @@ def _prove_pixel_model_contract(config: dict, contract: dict) -> bool: return False _verify_litellm_route(config, model='ods/current') return True + if _external_lemonade_runtime(config): + # An externally managed Lemonade process is the authority for its + # loaded model. The local GGUF_FILE can be an unrelated installer + # artifact, so it cannot prove either commit or rollback here. + observed = _read_external_lemonade_observation(config) + return ( + observed['modelId'] == contract['model'] + and observed['contextLength'] == contract['contextLength'] + and str(config.get('LEMONADE_MODEL') or '') == contract['model'] + and str(config.get('CTX_SIZE') or '') == str(contract['contextLength']) + and str(config.get('MAX_CONTEXT') or '') == str(contract['contextLength']) + ) gguf = str(config.get('GGUF_FILE') or '') if not gguf: return False @@ -3986,7 +4094,8 @@ def _recover_pixel_model_transaction(config: dict) -> dict: if (journal['phase']=='prepared' and not status['pending'] and status['contract']==journal['previous'] and 'unavailable' not in journal['before'].values() - and _pixel_model_config_digests()==journal['before']): + and _pixel_model_config_digests()==journal['before'] + and _prove_pixel_model_contract(config,journal['previous'])): journal.update(phase='completed',outcome='rollback') _atomic_write_json(_pixel_model_journal_path(),journal) return {'pending':False,'phase':'completed','transactionId':journal['transactionId'],'outcome':'rollback'} @@ -5169,7 +5278,11 @@ def _running_under_wsl( return "microsoft" in str(release).casefold() -def _resolve_agent_bind_addr(env: dict, system_name: str | None = None) -> str: +def _resolve_agent_bind_addr( + env: dict, + system_name: str | None = None, + require_ods_network: bool = False, +) -> str: """Resolve the host-agent bind address without exposing LAN by default.""" system_name = system_name or platform.system() explicit = env.get("ODS_AGENT_BIND", "").strip() @@ -5194,14 +5307,20 @@ def _resolve_agent_bind_addr(env: dict, system_name: str | None = None) -> str: return "127.0.0.1" if system_name == "Linux": - # Prefer ODS's actual compose network. The bridge fallback keeps - # older/partial installs reachable without binding the Docker - # management API to every LAN interface. - return ( - _detect_docker_network_gateway("ods-network") - or _detect_docker_bridge_gateway() - or "127.0.0.1" - ) + # A managed system service must not settle on the default bridge during + # boot before Compose restores ods-network. Dashboard API uses the ODS + # network gateway, so a successful bind to another bridge leaves Pixel + # and host-agent actions unreachable until someone restarts the unit. + gateway = _detect_docker_network_gateway("ods-network") + if gateway: + return gateway + if require_ods_network: + raise RuntimeError( + "ods-network is unavailable; refusing a fallback host-agent bind" + ) + # Preserve the compatibility path for unmanaged/session agents and + # partial installs, which do not have systemd restart supervision. + return _detect_docker_bridge_gateway() or "127.0.0.1" return "127.0.0.1" @@ -7060,6 +7179,8 @@ def do_GET(self): self._handle_model_list() elif path == "/v1/model/status": self._handle_model_status() + elif path == "/v1/model/external-observation": + self._handle_external_model_observation() elif path == "/v1/model/recovery": self._handle_model_recovery_status() elif path == "/v1/network/wifi-scan": @@ -7602,6 +7723,8 @@ def do_POST(self): self._handle_model_download_cancel() elif self.path == "/v1/model/activate": self._handle_model_activate() + elif self.path == "/v1/model/external-adopt": + self._handle_external_model_adopt() elif self.path == "/v1/model/recover": self._handle_model_recover() elif self.path == "/v1/remote-provider/plan": @@ -9851,6 +9974,97 @@ def _handle_model_status(self): _project_switchboard_agent_viability(data) json_response(self, 200, data) + def _handle_external_model_observation(self): + """Expose only a verified, nonsecret external runtime identity.""" + if not check_auth(self): + return + try: + env = load_env(INSTALL_DIR / ".env") + if not _external_lemonade_runtime(env): + json_response( + self, 409, {"error": "External Lemonade is not configured"}, + no_store=True, + ) + return + observed = _read_external_lemonade_observation(env) + except (OSError, ValueError, RuntimeError, urllib_error.URLError): + # Neither the configured origin nor upstream response is safe to + # reflect into an authenticated browser-visible error. + json_response( + self, 503, {"error": "External Lemonade identity is unavailable"}, + no_store=True, + ) + return + json_response(self, 200, { + "status": "verified", + "modelId": observed["modelId"], + "contextLength": observed["contextLength"], + "backend": observed["backend"], + }, no_store=True) + + def _handle_external_model_adopt(self): + """Converge ODS consumers on the already loaded external model.""" + if not check_auth(self): + return + body = read_json_body(self) + if body is None: + return + model_id = body.get("model_id") if isinstance(body, dict) else None + if not isinstance(body, dict) or set(body) != {"model_id"} or not _valid_pixel_model_name(model_id): + json_response(self, 400, {"error": "An exact model_id is required"}, no_store=True) + return + acquired, active = _begin_model_activation(model_id) + if not acquired: + json_response(self, 409, { + "error": "Another model lifecycle operation is in progress", + "code": "model_lifecycle_busy", "activeModelId": active, + }, no_store=True) + return + try: + result = _adopt_external_lemonade_model(model_id) + except ValueError: + json_response(self, 409, { + "error": "The requested external model does not match the loaded runtime", + "code": "external_model_mismatch", + }, no_store=True) + except _ExternalAdoptionReceiptUnavailable: + logger.exception("External Lemonade adoption committed without a dashboard receipt") + json_response(self, 503, { + "error": "The model route was committed, but its dashboard receipt could not be saved; check live status before retrying", + "code": "external_adoption_receipt_unavailable", "pending": False, + }, no_store=True) + except _PixelModelTransactionUncertain: + logger.exception("External Lemonade adoption could not be proved") + try: + pending = _pixel_model_recovery_status()["pending"] + except Exception: + pending = True + json_response(self, 503, { + "error": "External model adoption is incomplete; managed recovery is required", + "code": "managed_model_recovery_required", + "pending": pending, + }, no_store=True) + except Exception: + logger.exception("External Lemonade adoption preflight failed") + try: + pending = _pixel_model_recovery_status()["pending"] + except Exception: + pending = True + if pending: + json_response(self, 503, { + "error": "External model adoption is incomplete; managed recovery is required", + "code": "managed_model_recovery_required", "pending": True, + }, no_store=True) + else: + json_response(self, 503, { + "error": "External model adoption prerequisites are unavailable", + "code": "external_adoption_unavailable", "pending": False, + }, no_store=True) + else: + json_response(self, 200, result, no_store=True) + finally: + _end_model_activation() + def _handle_model_download(self): """Start async model download. Only one download at a time. @@ -10689,6 +10903,18 @@ def _do_model_activate( ) return + if _external_lemonade_runtime(persisted_env): + # Local GGUF activation owns the inference process and rolls back + # by restoring the previous physical model. Neither assumption is + # valid for a separately managed Lemonade service. Reject before + # looking up model files or changing any consumer configuration. + json_response(self, 409, { + "error": "Externally managed Lemonade cannot use local model activation", + "code": "external_runtime_unmanaged", + "requestedModelId": model_id, + }) + return + def local_gguf_model_from_id(raw_model_id: str) -> dict | None: matching = [] for store in _model_stores.registered_stores(INSTALL_DIR / "data", container=bool(os.environ.get("ODS_HOST_INSTALL_DIR"))): @@ -12677,6 +12903,248 @@ def _lemonade_loaded_model_entry( return None +def _verified_external_lemonade_observation(health: object, catalog: object) -> dict: + """Prove the one physically loaded external Lemonade model, without aliases. + + This deliberately does not adopt or publish a route. An external runtime + may change independently of ODS, so a stale switchboard record must keep + Pixel fail-closed until a separate transactional reconciliation succeeds. + """ + if not isinstance(health, dict) or health.get("status") != "ok": + raise ValueError("External Lemonade health is not verified") + model_id = health.get("model_loaded") + if ( + not _valid_pixel_model_name(model_id) + or "://" in model_id + or not isinstance(catalog, dict) + or not isinstance(catalog.get("data"), list) + ): + raise ValueError("External Lemonade identity is not verified") + loaded = health.get("all_models_loaded") + if not isinstance(loaded, list): + raise ValueError("External Lemonade loaded models are unavailable") + llms = [row for row in loaded if isinstance(row, dict) and row.get("type") == "llm"] + if len(llms) != 1 or llms[0].get("model_name") != model_id: + raise ValueError("External Lemonade loaded LLM is ambiguous") + row = llms[0] + options = row.get("recipe_options") + context = options.get("ctx_size") if isinstance(options, dict) else None + backend = options.get("llamacpp_backend") if isinstance(options, dict) else None + checkpoint = row.get("checkpoint") + if ( + row.get("recipe") != "llamacpp" + or not isinstance(checkpoint, str) + or not checkpoint.strip() + or type(context) is not int + or not 4096 <= context <= 10_000_000 + or backend not in {"vulkan", "rocm", "metal", "cpu"} + ): + raise ValueError("External Lemonade runtime contract is incomplete") + matches = [ + item for item in catalog["data"] + if isinstance(item, dict) and item.get("id") == model_id + ] + if ( + len(matches) != 1 + or matches[0].get("downloaded") is not True + or matches[0].get("recipe") != "llamacpp" + or matches[0].get("checkpoint") != checkpoint + ): + raise ValueError("External Lemonade catalog does not prove the loaded checkpoint") + return { + "modelId": model_id, + "checkpoint": checkpoint, + "contextLength": context, + "backend": backend, + } + + +def _read_external_lemonade_observation(env: dict) -> dict: + """Read bounded health/catalog/health observations from one fixed origin.""" + if not _external_lemonade_runtime(env): + raise ValueError("External Lemonade is not configured") + base_url = _lemonade_runtime_base_url(env) + if not base_url: + raise ValueError("External Lemonade origin is invalid") + opener = urllib_request.build_opener( + urllib_request.ProxyHandler({}), _BackendHealthNoRedirect() + ) + payloads = [] + for path in ("/api/v1/health", "/api/v1/models", "/api/v1/health"): + request = urllib_request.Request( + f"{base_url}{path}", headers={"Accept": "application/json"} + ) + with opener.open(request, timeout=5) as response: + raw = response.read(4 * 1024 * 1024 + 1) + if len(raw) > 4 * 1024 * 1024: + raise ValueError("External Lemonade response is too large") + payloads.append(json.loads(raw.decode("utf-8"))) + observed = _verified_external_lemonade_observation(payloads[0], payloads[1]) + if _verified_external_lemonade_observation(payloads[2], payloads[1]) != observed: + raise ValueError("External Lemonade identity changed during observation") + return observed + + +def _adopt_external_lemonade_model(expected_model_id: str) -> dict: + """Forward-only reconciliation after a separately managed model switch. + + ODS never attempts to load, stop, or restore the native Lemonade process. + A failure before proven native completion remains pending; a receipt + failure after commit is reported separately without inventing a hold. + """ + env_path = INSTALL_DIR / ".env" + env = load_env(env_path) + if not _external_lemonade_runtime(env): + raise ValueError("External Lemonade is not configured") + observed = _read_external_lemonade_observation(env) + if observed["modelId"] != expected_model_id: + raise ValueError("The loaded model differs from the requested model") + context_length = observed["contextLength"] + if context_length < _MIN_MANAGED_PIXEL_CONTEXT: + raise ValueError("The loaded model context is too small for managed Pixel") + if not env.get("PIXEL_OPENWEBUI_KEY") or _switchboard_state is None: + raise RuntimeError("External adoption requires managed Pixel and switchboard") + + target = { + "model": expected_model_id, + "contextLength": context_length, + "maxTokens": _pixel_max_tokens_for_context(context_length), + "reasoning": _pixel_model_reasoning_capable(expected_model_id, env), + } + original_env = _snapshot_text_file(env_path) + hermes_path = INSTALL_DIR / "data" / "hermes" / "config.yaml" + hermes_template = INSTALL_DIR / "extensions" / "services" / "hermes" / "cli-config.yaml.template" + hermes_snapshot = _capture_hermes_live_config(hermes_path) + opencode_snapshot = _capture_opencode_config() + opencode_state = _capture_managed_opencode_state() if opencode_snapshot is not None else None + states = {name: _capture_container_state(name) for name in ( + "ods-litellm", "ods-hermes", "ods-openclaw", "ods-perplexica", + )} + if not states["ods-litellm"]["running"]: + raise RuntimeError("LiteLLM must be running to adopt an external model") + if states["ods-hermes"]["running"] and hermes_snapshot.get("source") == "deferred_absent": + raise RuntimeError("Running Hermes configuration cannot be captured") + perplexica_snapshot = _capture_perplexica_config(env, states["ods-perplexica"]) + _assert_text_file_matches_snapshot(env_path, original_env) + + # Hold both native Pixel gates before the first host-side write. The + # physical switch may have preceded this request; Pixel's live-model + # identity check rejects the old route during that pre-adoption gap. + transaction = _begin_or_resume_external_pixel_transaction(env, target) + try: + _assert_text_file_matches_snapshot(env_path, original_env) + updated = str(original_env.get("text") or "") + for key, value in ( + ("LEMONADE_MODEL", expected_model_id), + ("LLM_MODEL", expected_model_id), + ("CTX_SIZE", str(context_length)), + ("MAX_CONTEXT", str(context_length)), + ("MODEL_SELECTION_SOURCE", "external-lemonade-adoption"), + ): + updated = _upsert_env_text(updated, key, value) + _write_bound_env_text(env_path, updated) + current_env = load_env(env_path) + if not _prove_pixel_model_contract(current_env, target): + raise RuntimeError("The native model changed before consumer reconciliation") + + # GGUF_FILE is a local installer artifact on this topology, not the + # physical Windows checkpoint. Every active route receives the exact + # native model ID explicitly; no local GGUF lookup or load is attempted. + gguf_file = str(current_env.get("GGUF_FILE") or "") + _write_lemonade_config(INSTALL_DIR, gguf_file, expected_model_id) + _render_model_router_runtime_configs( + INSTALL_DIR, current_env, model=expected_model_id, + gguf_file=gguf_file, lemonade_model_id=expected_model_id, + context_length=context_length, + ) + hermes_base_url = current_env.get("HERMES_LLM_BASE_URL") or "http://litellm:4000/v1" + if hermes_snapshot.get("exists") and hermes_snapshot.get("source") != "deferred_absent": + patched, _changed = _patch_hermes_config_text( + str(hermes_snapshot.get("text") or ""), expected_model_id, + base_url=hermes_base_url, context_length=context_length, + ) + _write_hermes_live_config( + hermes_path, patched, hermes_snapshot.get("source"), + hermes_snapshot.get("mode"), + ) + if not _hermes_config_matches(patched, expected_model_id, hermes_base_url, context_length): + raise RuntimeError("Hermes route could not be verified") + _patch_hermes_model_config( + hermes_template, expected_model_id, base_url=hermes_base_url, + context_length=context_length, + ) + if opencode_snapshot is not None: + _update_opencode_config( + current_env, opencode_snapshot, expected_model_id, + context_length, display_name=expected_model_id, + ) + if perplexica_snapshot is not None: + _update_perplexica_model( + current_env, perplexica_snapshot, gguf_file=gguf_file, + lemonade_model_id=expected_model_id, + ) + _restart_existing_container("ods-litellm", states["ods-litellm"], recreate=True) + _wait_for_container_health("ods-litellm") + # LiteLLM's public alias goes through model-router, whose active + # model-state is independent of the rendered endpoints/config. Prove + # the native target is still loaded, then publish it *before* asking + # the alias for a completion. Otherwise that probe routes to the old + # model and Lemonade auto-loads it, evicting this external target. + if _read_external_lemonade_observation(current_env) != observed or \ + not _prove_pixel_model_contract(current_env, target): + raise RuntimeError("The native model changed before route publication") + if not _external_adoption_route_published(expected_model_id, context_length): + _publish_activation_route( + current_env, expected_model_id, + {"identity": expected_model_id, "contextLength": context_length, + "contextVerified": True}, + _external_adoption_capabilities(expected_model_id, context_length), + ) + _verify_litellm_route(current_env) + if states["ods-hermes"]["running"]: + _restart_existing_container("ods-hermes", states["ods-hermes"], recreate=True) + _wait_for_container_health("ods-hermes") + _verify_running_hermes_route(expected_model_id, hermes_base_url, context_length) + if states["ods-openclaw"]["running"]: + _recreate_openclaw_if_present(states["ods-openclaw"]) + _verify_openclaw_model_env(expected_model_id) + _wait_for_container_health("ods-openclaw") + if opencode_state and opencode_state.get("active"): + _restart_managed_opencode(opencode_state) + + final = _read_external_lemonade_observation(current_env) + if final != observed or not _prove_pixel_model_contract(current_env, target): + raise RuntimeError("The native model changed during consumer reconciliation") + if transaction.journal['phase'] != 'applied': + transaction.apply(target) + transaction.finish("commit") + except Exception as exc: + # Restoring ODS's old files would lie: native Lemonade may still be + # serving B. Preserve the durable journal for proof instead of + # invoking local activation's runtime rollback. + raise _PixelModelTransactionUncertain( + "External adoption is incomplete; physical model and consumers require repair" + ) from exc + # Receipt I/O is outside the held transaction. A failure here must never + # misreport an already committed route as a still-held Pixel transition. + try: + _atomic_write_json(INSTALL_DIR / "data" / "model-activation-receipt.json", { + "schema": "ods.model-activation-receipt.v1", + "status": "complete", "source": "external-lemonade-adoption", + "modelId": expected_model_id, "runtimeModelId": expected_model_id, + "contextLength": context_length, "contextVerified": True, + "modelTransactionId": transaction.id, "verifiedAt": _iso_now(), + }) + except Exception as exc: + raise _ExternalAdoptionReceiptUnavailable( + "External model route committed but activation receipt could not be saved" + ) from exc + return { + "status": "adopted", "modelId": expected_model_id, + "contextLength": context_length, "modelTransactionId": transaction.id, + } + + def _lemonade_loaded_context_length( body_or_data: str | dict, *, @@ -16399,6 +16867,10 @@ def main(): parser.add_argument("--port", type=int, default=7710, help="Listen port (default: 7710)") parser.add_argument("--pid-file", type=str, default="", help="Write PID to this file") parser.add_argument("--install-dir", type=str, default="", help="ODS install directory") + parser.add_argument( + "--require-ods-network", action="store_true", + help="Fail closed until the ODS Docker network exists (systemd will retry)", + ) args = parser.parse_args() logging.basicConfig( @@ -16471,7 +16943,13 @@ def main(): # keeps the loopback path because its reported bridge is not locally bindable. # The bridge gateway fallback keeps partial/older native-Linux installs # reachable until phase 11 can restart the service after ods-network exists. - bind_addr = _resolve_agent_bind_addr(env) + try: + bind_addr = _resolve_agent_bind_addr( + env, require_ods_network=args.require_ods_network + ) + except RuntimeError as exc: + logger.error("%s", exc) + sys.exit(1) server = _create_host_agent_server(env, bind_addr, port) signal.signal(signal.SIGTERM, lambda signum, _frame: _request_server_shutdown(server, signum)) diff --git a/ods/bin/pixel_access_bridge.py b/ods/bin/pixel_access_bridge.py index e7a4aabcdf..b34445b687 100644 --- a/ods/bin/pixel_access_bridge.py +++ b/ods/bin/pixel_access_bridge.py @@ -999,9 +999,22 @@ def remove_model_journal(self): finally: os.close(directory) def model_completion(self, request=None): - path = self.state / "model-completed.json" + path = self.state / "model-promotion-completed.json" + legacy = False + if not path.exists(): + path = self.state / "model-completed.json" + legacy = True if not path.exists(): return None value = private_json(path, 0, 4096) + # Older releases shared this filename with browser model switching. + # A valid receipt from that separate transaction is not a promotion + # completion; malformed state still fails closed. + if legacy and type(value) is dict and set(value) == {"transactionId", "outcome", "configSha256"}: + if (type(value["transactionId"]) is not str or not HEX.fullmatch(value["transactionId"]) + or value["outcome"] not in ("commit", "rollback") + or type(value["configSha256"]) is not str or not HEX.fullmatch(value["configSha256"])): + raise AccessError("model-recovery-required") + return None if (type(value) is not dict or set(value) != {"kind", "transaction_id", "outcome", "config_sha256"} or value.get("kind") != "model-completion" @@ -1181,7 +1194,7 @@ def model_finish(self, request): or released_edge.get("streams")): raise try: - atomic_json(self.state / "model-completed.json", { + atomic_json(self.state / "model-promotion-completed.json", { "kind": "model-completion", "transaction_id": pending["transaction_id"], "outcome": request["outcome"], "config_sha256": config["config_sha256"]}) except OSError: diff --git a/ods/bin/pixel_model_coordinator.py b/ods/bin/pixel_model_coordinator.py index 2cd6cb9fd5..2f1c16f65d 100644 --- a/ods/bin/pixel_model_coordinator.py +++ b/ods/bin/pixel_model_coordinator.py @@ -2,6 +2,8 @@ import hashlib import json import os +import stat +import tempfile import time from pixel_access_bridge import AccessError, UNIT, atomic_json, private_json, digest, remaining from pixel_settings.coordinator import _read, _identity, _valid_identity @@ -14,7 +16,7 @@ def _sha(value): def _journal(value): required = {"kind", "phase", "token", "transactionId", "edge_revision", "edgeHeld", "beforeSha", "afterSha", "target", "boundary", "mode", "beforeIdentity"} - if (type(value) is not dict or set(value) - required - {"outcome"} or not required <= set(value) + if (type(value) is not dict or set(value) - required - {"outcome", "markerBeforeSha"} or not required <= set(value) or value["kind"] != "model" or value["phase"] not in ("acquiring", "held", "applying", "applied", "restoring", "releasing") or any(not checksum(value[key]) for key in ("token", "transactionId", "edge_revision", "beforeSha")) or value["afterSha"] is not None and not checksum(value["afterSha"]) @@ -24,6 +26,8 @@ def _journal(value): or type(value["edgeHeld"]) is not bool or "outcome" in value and value["outcome"] not in ("commit", "rollback")): raise AccessError("invalid-model-transition") + if "markerBeforeSha" in value and not checksum(value["markerBeforeSha"]): + raise AccessError("invalid-model-transition") if value["target"] is not None: target(value["target"]) return value @@ -36,6 +40,76 @@ def _config(bridge): return _read(bridge.home / ".openclaw/openclaw.json", bridge.owner.pw_uid) +def _marker_digest(config): + if type(config) is not dict: + raise AccessError("model-marker-invalid") + canonical = json.dumps(config, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(b"ods-pixel-openclaw-v1\0" + canonical).hexdigest() + + +def _managed_marker(bridge): + path = bridge.home / ".config/ods/pixel-managed.json" + for directory, unsafe_bits in ((path.parent.parent, 0o022), (path.parent, 0o077)): + info = directory.lstat() + if (not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode) + or info.st_uid != bridge.owner.pw_uid or info.st_mode & unsafe_bits): + raise AccessError("model-marker-unsafe") + marker = private_json(path, bridge.owner.pw_uid, 65536) + if (type(marker) is not dict or marker.get("schema_version") != 2 + or marker.get("manager") != "ods" or marker.get("state") != "ready" + or marker.get("initial_active_state") != "absent" + or marker.get("install_dir") != str(bridge.install) + or type(marker.get("configuration_sha256")) is not str + or not checksum(marker["configuration_sha256"])): + raise AccessError("model-marker-invalid") + return path, marker + + +def _bind_managed_marker(bridge, journal, expected_sha): + before = private_json(bridge.state / "model-before.json", 0, 8 * 1024 * 1024) + prior = _marker_digest(before) + # Pre-upgrade journals did not carry markerBeforeSha. Their root-owned + # model-before snapshot and the still-bound owner marker can prove the + # same prior configuration without silently adopting unrelated drift. + if prior != journal.get("markerBeforeSha", prior): + raise AccessError("model-before-changed") + config, config_sha = _config(bridge) + if config_sha != expected_sha: + raise AccessError("model-config-changed") + path, marker = _managed_marker(bridge) + current = _marker_digest(config) + if marker["configuration_sha256"] == current: + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: os.fsync(directory) + finally: os.close(directory) + return # A retry after the marker rename is idempotent. + if marker["configuration_sha256"] != prior: + raise AccessError("model-marker-drifted") + original = path.lstat() + marker["configuration_sha256"] = current + fd, temporary = tempfile.mkstemp(prefix=".pixel-managed.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + if os.geteuid() != bridge.owner.pw_uid or os.getegid() != bridge.owner.pw_gid: + os.fchown(handle.fileno(), bridge.owner.pw_uid, bridge.owner.pw_gid) + os.fchmod(handle.fileno(), 0o600) + json.dump(marker, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + observed = path.lstat() + if ((observed.st_dev, observed.st_ino, observed.st_mtime_ns, observed.st_size) + != (original.st_dev, original.st_ino, original.st_mtime_ns, original.st_size)): + raise AccessError("model-marker-drifted") + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: os.fsync(directory) + finally: os.close(directory) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + def _readback(bridge, journal=None): native = bridge.native() if native.get("stopped"): raise AccessError("model-runtime-unavailable") @@ -142,8 +216,21 @@ def _status(bridge): raise AccessError("model-runtime-mismatch") if (_config(bridge)[1] != config_sha or _identity(bridge) != identity or native.get("revision") != observed["revision"]): raise AccessError("model-inspection-changed") - done_path = bridge.state / "model-completed.json" + done_path = bridge.state / "model-route-completed.json" + legacy = False + if not done_path.exists(): + done_path = bridge.state / "model-completed.json" + legacy = True done = private_json(done_path, 0, 8192) if done_path.exists() else None + # The legacy filename was also used by install-time model promotion. + # Accept its valid receipt as belonging to that other transaction, never + # as evidence that a browser model switch completed. + if legacy and type(done) is dict and set(done) == {"kind", "transaction_id", "outcome", "config_sha256"}: + if (done["kind"] != "model-completion" or not checksum(done["transaction_id"]) + or done["outcome"] not in ("applied", "rolled-back") + or not checksum(done["config_sha256"])): + raise AccessError("invalid-model-completion") + done = None if done and (not checksum(done.get("transactionId")) or done.get("outcome") not in ("commit", "rollback") or not checksum(done.get("configSha256"))): raise AccessError("invalid-model-completion") done = done if done and done["configSha256"] == config_sha else None @@ -180,9 +267,13 @@ def control(bridge, operation, request=None): if access["busy"]: raise AccessError("runtime-busy") if access["configured_mode"] not in ("sandboxed", "full-access"): raise AccessError("model-access-mode-unknown") config, config_sha = _config(bridge) + _, marker = _managed_marker(bridge) + if marker["configuration_sha256"] != _marker_digest(config): + raise AccessError("model-marker-drifted") journal = dict(kind="model", phase="acquiring", token=os.urandom(32).hex(), transactionId=request["transactionId"], edge_revision=access["_edge"]["revision"], edgeHeld=False, beforeSha=config_sha, afterSha=None, target=None, - boundary=bridge.unit_boundary(), mode=access["configured_mode"], beforeIdentity=_identity(bridge)) + boundary=bridge.unit_boundary(), mode=access["configured_mode"], beforeIdentity=_identity(bridge), + markerBeforeSha=marker["configuration_sha256"]) atomic_json(bridge.state / "model-before.json", config) _write(bridge, journal) _hold(bridge, journal) @@ -233,8 +324,9 @@ def control(bridge, operation, request=None): "config_sha256": expected_sha, "boundary": journal["boundary"]}) _verify(bridge, journal, expected_sha) if owner["pending"]: worker("model-finish", model_outcome=outcome) + _bind_managed_marker(bridge, journal, expected_sha) journal.update(phase="releasing", outcome=outcome); _write(bridge, journal) - atomic_json(bridge.state / "model-completed.json", {"transactionId": journal["transactionId"], "outcome": outcome, "configSha256": expected_sha}) + atomic_json(bridge.state / "model-route-completed.json", {"transactionId": journal["transactionId"], "outcome": outcome, "configSha256": expected_sha}) bridge.edge("release", journal["token"], journal["edge_revision"]) bridge.native("release", journal["token"]) try: diff --git a/ods/config/extensions-catalog.json b/ods/config/extensions-catalog.json index 1ad42c3658..6d5d266638 100644 --- a/ods/config/extensions-catalog.json +++ b/ods/config/extensions-catalog.json @@ -10,7 +10,9 @@ "gpu_backends": [ "amd", "nvidia", - "apple" + "apple", + "cpu", + "none" ], "compose_file": "compose.yaml", "depends_on": [], diff --git a/ods/config/model-library.json b/ods/config/model-library.json index d60c5a8334..c106e5430e 100644 --- a/ods/config/model-library.json +++ b/ods/config/model-library.json @@ -356,6 +356,19 @@ "tokens_per_sec_estimate": 80, "llm_model_name": "granite-4.0-h-tiny", "llama_server_image": null, + "app_compatibility": { + "opencode": { + "status": "unsupported_until_revalidated", + "label": "OpenCode revalidation required", + "reason": "In the 2026-09-17 Tower1 exact-head release cycle, OpenCode's build agent issued a read tool call against a fabricated path instead of returning its requested verification phrase on Granite 4.0 H-Tiny. The same installed OpenCode path answered on the baseline and neighboring Granite 4.0 models; Pixel, ODS Talk, LiteLLM, Open WebUI, and Perplexica passed on H-Tiny with exact model-route evidence. Keep this model out of Tower1 all-app release coverage until OpenCode behavior is revalidated.", + "evidence": "ods-public-beta-fleet-green-20260911/release-tower1-pr5579-471bc50-135daf6-r216/model-ui/cycle-002/tower1/model-ui.json", + "recordedAt": "2026-09-17T08:46:03Z", + "productSha": "471bc50df3b3a127aa83ac208c7220e19493d8f6", + "harnessSha": "135daf604399fe757e8364d3721767a22bafe541", + "hostScope": ["tower1"], + "expiresAt": "2026-10-17T00:00:00Z" + } + }, "install_recommendation": false }, { @@ -779,12 +792,13 @@ "perplexica": { "status": "unsupported_until_revalidated", "label": "Perplexica revalidation required", - "reason": "Earlier fleet runs found Perplexica nonce failures with this model on Tower2 and M5. A 2026-09-15 Tower3 public-beta run again returned an apologetic Perplexica response without the verification phrase, while ODS Talk, LiteLLM, Open WebUI, and Pixel routed and answered on the selected Granite model. Perplexica answered the same harmless payload after Tower3 recovered its Qwen3.5-27B model. Keep Granite 3.2 2B out of all-app release coverage on these hosts until Perplexica passes a real revalidation.", - "evidence": "fleet-test/runs/2026-07-23T21-41-59Z-release-product-cf6dffb8e8bb-harness-47eedd6c0b40-hosts-six-scope-6cycle-switchboard/model-ui/cycle-005/tower2; fleet-test/runs/2026-07-23T21-41-59Z-release-product-cf6dffb8e8bb-harness-47eedd6c0b40-hosts-six-scope-6cycle-switchboard/model-ui/cycle-005/m5-mbp; ods-public-beta-fleet-green-20260911/release-four-host-2f985137-abb61b2-r46/model-ui/cycle-006/tower3/model-ui.json; Tower3 Perplexica live comparator 2026-09-15T16:59Z", - "recordedAt": "2026-09-15T16:59:00Z", - "productSha": "2f985137b9680151395d59d0498a7fa7e169ff2e", - "harnessSha": "abb61b2586cb347e0a163d4f8feb2acf74e9cee0", - "hostScope": ["tower2", "m5-mbp", "tower3"] + "reason": "Fleet runs on Tower2, M5, Tower3, and Tower1 found Perplexica nonce failures with this model. On the 2026-09-17 Tower1 public-beta candidate, Perplexica returned an apologetic response without the verification phrase while Pixel, OpenCode, LiteLLM, Open WebUI, and ODS Talk answered on the selected Granite model. A Tower3 comparator answered the same harmless payload after restoring Qwen3.5-27B. Keep Granite 3.2 2B out of all-app release coverage on these hosts until Perplexica passes a real revalidation.", + "evidence": "fleet-test/runs/2026-07-23T21-41-59Z-release-product-cf6dffb8e8bb-harness-47eedd6c0b40-hosts-six-scope-6cycle-switchboard/model-ui/cycle-005/tower2; fleet-test/runs/2026-07-23T21-41-59Z-release-product-cf6dffb8e8bb-harness-47eedd6c0b40-hosts-six-scope-6cycle-switchboard/model-ui/cycle-005/m5-mbp; ods-public-beta-fleet-green-20260911/release-four-host-2f985137-abb61b2-r46/model-ui/cycle-006/tower3/model-ui.json; Tower3 Perplexica live comparator 2026-09-15T16:59Z; ods-public-beta-fleet-green-20260911/release-tower1-pr5579-afe7408-ff56b22-r208/model-ui/cycle-006/tower1/model-ui.json", + "recordedAt": "2026-09-17T01:19:42Z", + "productSha": "afe74085e572c167f44563efc68476f1a910e179", + "harnessSha": "ff56b224a69d8b955a23fd24af8407c9ccf6b4e8", + "hostScope": ["tower2", "m5-mbp", "tower3", "tower1"], + "expiresAt": "2026-10-17T00:00:00Z" }, "hermes_talk": { "status": "unsupported_until_revalidated", diff --git a/ods/docker-compose.cloud.yml b/ods/docker-compose.cloud.yml index 1fe6f5b96f..6f4fc45469 100644 --- a/ods/docker-compose.cloud.yml +++ b/ods/docker-compose.cloud.yml @@ -16,11 +16,3 @@ services: profiles: - local-inference restart: "no" - - # Pixel's model relay has a hard dependency on model-router. Keep their - # cloud-mode profile in lockstep so an enabled local install remains a valid - # Compose project when the operator checks or switches to cloud mode. - pixel-model-relay: - profiles: - - local-inference - restart: "no" diff --git a/ods/docker-compose.external-llm.yml b/ods/docker-compose.external-llm.yml index 2cf0e7b86f..4b90c43a81 100644 --- a/ods/docker-compose.external-llm.yml +++ b/ods/docker-compose.external-llm.yml @@ -25,6 +25,10 @@ services: - "host.docker.internal:host-gateway" environment: LLM_BACKEND: external + EXTERNAL_LLM_CONTAINER_URL: "${EXTERNAL_LLM_CONTAINER_URL}" + EXTERNAL_LLM_PROVIDER: "${EXTERNAL_LLM_PROVIDER:-openai-compatible}" + EXTERNAL_LLM_MODEL: "${EXTERNAL_LLM_MODEL:-}" + ODS_MODEL_SWITCHBOARD: "${ODS_MODEL_SWITCHBOARD:-observe}" LLM_API_BASE_PATH: /v1 ODS_TALK_VISION_URL: "${EXTERNAL_LLM_CONTAINER_URL}/v1" AMD_INFERENCE_RUNTIME: "" diff --git a/ods/docker-compose.lemonade-external.yml b/ods/docker-compose.lemonade-external.yml index 9f1401abc8..d1f0c337aa 100644 --- a/ods/docker-compose.lemonade-external.yml +++ b/ods/docker-compose.lemonade-external.yml @@ -1,14 +1,13 @@ # ODS - external Lemonade SDK runtime overlay # # Use when Lemonade is already installed and running on the host. This overlay -# keeps ODS's managed llama-server disabled and routes ODS services -# through LiteLLM, which calls the existing Lemonade service. +# keeps ODS's managed llama-server disabled while retaining model-router for +# Pixel and browser model switching against the existing Lemonade service. # # This file is intentionally not standalone. It is selected by # scripts/resolve-compose-stack.sh together with: # # docker-compose.base.yml -# docker-compose.cloud.yml # extensions/services/litellm/compose.yaml # docker-compose.lemonade-external.yml # @@ -18,6 +17,15 @@ # ./scripts/resolve-compose-stack.sh --ods-mode lemonade --gpu-backend amd --tier SH_LARGE services: + llama-server: + profiles: + - local-inference + restart: "no" + + model-router: + extra_hosts: + - "host.docker.internal:host-gateway" + litellm: extra_hosts: - "host.docker.internal:host-gateway" diff --git a/ods/docs/ODS-DOCTOR.md b/ods/docs/ODS-DOCTOR.md index b2023260f8..2c5746452b 100644 --- a/ods/docs/ODS-DOCTOR.md +++ b/ods/docs/ODS-DOCTOR.md @@ -135,8 +135,8 @@ JSON. ODS supports several deployment shapes, but support cases often fail when the install metadata and runtime routing disagree. For example, cloud mode should not start or target ODS's managed `llama-server`, and external -Lemonade should route ODS services through LiteLLM while leaving Lemonade -itself host-managed. +Lemonade should remain host-managed while ODS keeps LiteLLM and model-router +available for clients, Pixel, and model switching. ODS Doctor records those expectations under `runtime.inference_contract` and adds diagnoses when the evidence contradicts the selected mode: @@ -150,9 +150,10 @@ adds diagnoses when the evidence contradicts the selected mode: at local `llama-server`. - `ODS-RUNTIME-CLOUD-GATEWAY-BYPASS`: cloud mode points ODS services somewhere other than the LiteLLM gateway. -- `ODS-RUNTIME-EXTERNAL-LEMONADE-CLOUD-OVERLAY-MISSING`: external Lemonade is - active while cached `.compose-flags` lacks the cloud overlay that profiles - out managed local inference. +- `ODS-RUNTIME-EXTERNAL-LEMONADE-CLOUD-OVERLAY-CONFLICT`: external Lemonade is + active while cached `.compose-flags` includes the cloud overlay, which + incorrectly profiles out model-router. The dedicated Lemonade overlay + profiles out only managed `llama-server`. - `ODS-RUNTIME-EXTERNAL-LEMONADE-OVERLAY-MISSING`: external Lemonade is active while cached `.compose-flags` lacks `docker-compose.lemonade-external.yml`. - `ODS-RUNTIME-EXTERNAL-LEMONADE-LOCAL-ROUTE`: external Lemonade still routes diff --git a/ods/extensions/library/services/aider/manifest.yaml b/ods/extensions/library/services/aider/manifest.yaml index 10cf18091b..7123b7b778 100644 --- a/ods/extensions/library/services/aider/manifest.yaml +++ b/ods/extensions/library/services/aider/manifest.yaml @@ -13,9 +13,9 @@ service: health: "" type: docker startup_check: false # one-shot CLI tool; container exits 0 by design - # Aider is a one-shot CLI container and does not require GPU access. `none` - # keeps it installable when ODS selects the CPU fallback backend. - gpu_backends: [amd, nvidia, apple, none] + # Aider is a one-shot CLI container and does not require GPU access. ODS + # writes GPU_BACKEND=cpu for its fallback; retain legacy `none` as well. + gpu_backends: [amd, nvidia, apple, cpu, none] compose_file: compose.yaml category: optional depends_on: [] @@ -38,6 +38,7 @@ features: description: AI pair programming in your terminal icon: Terminal category: development + gpu_backends: [amd, nvidia, apple, cpu, none] requirements: services: [aider] vram_gb: 0 diff --git a/ods/extensions/services/dashboard-api/models.py b/ods/extensions/services/dashboard-api/models.py index e6df38d922..1b21161e60 100644 --- a/ods/extensions/services/dashboard-api/models.py +++ b/ods/extensions/services/dashboard-api/models.py @@ -183,11 +183,11 @@ class ModelLibraryEntry(BaseModel): downloadUrl: Optional[str] = None downloadSha256: Optional[str] = None llmModelName: Optional[str] = None - size: str - sizeGb: float - vramRequired: float + size: Optional[str] + sizeGb: Optional[float] + vramRequired: Optional[float] estimatedRequired: Optional[float] = None - contextLength: int + contextLength: Optional[int] maxContextLength: Optional[int] = None contextOptions: list[dict[str, Any]] = Field(default_factory=list) specialty: str @@ -205,9 +205,9 @@ class ModelLibraryEntry(BaseModel): recommended: bool = False configured: bool = False recommendation: Optional[dict[str, Any]] = None - fitsVram: bool + fitsVram: Optional[bool] activationSupport: Optional[dict[str, Any]] = None - fitsCurrentVram: bool + fitsCurrentVram: Optional[bool] performance: Optional[dict[str, Any]] = None performanceLabel: Optional[str] = None @@ -234,3 +234,4 @@ class ModelLibraryResponse(BaseModel): odsMode: str = "unknown" configuredMode: str = "unknown" llmBackend: str = "unknown" + externalLemonade: bool = False diff --git a/ods/extensions/services/dashboard-api/performance_oracle.py b/ods/extensions/services/dashboard-api/performance_oracle.py index cb4e0d3ea2..e2daaea89e 100644 --- a/ods/extensions/services/dashboard-api/performance_oracle.py +++ b/ods/extensions/services/dashboard-api/performance_oracle.py @@ -11,6 +11,7 @@ from __future__ import annotations +import hashlib import json import os import platform @@ -1575,6 +1576,33 @@ def append_model(model: dict[str, Any], path: Path | None, status_if_not_loaded: } append_model(fallback, path, "downloaded") + if isinstance(loaded_model, str) and loaded_model.strip() and current_model_id is None: + # An external runtime can report a model that is neither in our catalog + # nor an inspectable local GGUF. Show what is actually running without + # borrowing a different quantization's size, fit, or activation claims. + response_models.append({ + "id": f"runtime-{hashlib.sha256(loaded_model.encode('utf-8')).hexdigest()[:12]}", + "name": loaded_model, + "gguf": None, + "downloadUrl": None, + "size": None, + "sizeGb": None, + "vramRequired": None, + "estimatedRequired": None, + "contextLength": context_length, + "specialty": "Runtime", + "description": "Reported as loaded by the model runtime; not an ODS catalog or inspected local model.", + "metadata": {"source": "runtime", "catalogSource": "runtime", "readable": False}, + "appCompatibility": {}, + "status": "loaded", + "recommended": False, + "configured": False, + "fitsVram": None, + "activationSupport": None, + "fitsCurrentVram": None, + "performance": None, + }) + return { "models": response_models, "gpu": gpu_data, diff --git a/ods/extensions/services/dashboard-api/routers/models.py b/ods/extensions/services/dashboard-api/routers/models.py index 475e2533d5..4fd521e70a 100644 --- a/ods/extensions/services/dashboard-api/routers/models.py +++ b/ods/extensions/services/dashboard-api/routers/models.py @@ -29,6 +29,7 @@ ODS_MODE_EFFECTIVE, SERVICES, normalize_ods_mode, + read_live_env_values, ) from gpu import get_gpu_info from helpers import ( @@ -76,6 +77,30 @@ def _installed_model_paths() -> dict[str, Path]: def _installed_model_path(filename: str) -> Path | None: return next((path for name, path in _installed_model_paths().items() if name.casefold() == filename.casefold()), None) _ENV_PATH = Path(INSTALL_DIR) / ".env" + + +def _external_lemonade_runtime() -> bool: + """Whether Lemonade is owned by the host rather than this ODS install.""" + env = read_live_env_values(( + "LEMONADE_EXTERNAL", "AMD_INFERENCE_RUNTIME_MODE", "AMD_INFERENCE_MANAGED", + "ODS_MODE", "LLM_BACKEND", "AMD_INFERENCE_RUNTIME", + )) + runtime_mode = str(env.get("AMD_INFERENCE_RUNTIME_MODE") or "").strip().casefold() + managed = str(env.get("AMD_INFERENCE_MANAGED") or "").strip().casefold() + external = str(env.get("LEMONADE_EXTERNAL") or "").strip().casefold() + return ( + runtime_mode == "external-lemonade" + or external in {"1", "true", "yes", "on"} + or ( + managed in {"0", "false", "no", "off"} + and any( + str(env.get(key) or "").strip().casefold() == "lemonade" + for key in ("ODS_MODE", "LLM_BACKEND", "AMD_INFERENCE_RUNTIME") + ) + ) + ) + + _HF_API_BASE = "https://huggingface.co" _HF_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") _HF_AUTHOR_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") @@ -353,6 +378,14 @@ def _strip_llm_api_suffix(base_url: str) -> str: def _configured_llm_base_url(host: str, port: int) -> str: + # LiteLLM's LLM_API_URL is an alias gateway, not the physical Lemonade + # runtime. Model identity and readiness probes must follow the same + # backend endpoint as the installed host-inference route. + if LLM_BACKEND == "lemonade": + for key in ("LEMONADE_CONTAINER_BASE_URL", "LEMONADE_BASE_URL"): + value = read_env_value(key, INSTALL_DIR) + if value: + return _strip_llm_api_suffix(value) for key in ("LLM_URL", "LLM_API_URL", "OLLAMA_URL"): value = read_env_value(key, INSTALL_DIR) if value: @@ -1350,8 +1383,8 @@ async def list_models(api_key: str = Depends(verify_api_key)): payload, _model_lifecycle_from_agent_status(agent_status), ) - if gpu_info and loaded_model and live_tps > 0: - loaded_entry = next((m for m in payload["models"] if m["status"] == "loaded"), None) or {} + loaded_entry = next((m for m in payload["models"] if m["status"] == "loaded"), None) or {} + if gpu_info and loaded_model and live_tps > 0 and loaded_entry.get("metadata", {}).get("source") != "runtime": signature = build_sample_signature( loaded_entry or {"id": loaded_model, "gguf": _read_active_model()}, gpu_info, @@ -1378,7 +1411,7 @@ async def list_models(api_key: str = Depends(verify_api_key)): payload["odsMode"] = ODS_MODE_EFFECTIVE payload["configuredMode"] = _configured_ods_mode() payload["llmBackend"] = LLM_BACKEND or "unknown" - loaded_entry = next((model for model in payload["models"] if model["status"] == "loaded"), None) + payload["externalLemonade"] = _external_lemonade_runtime() payload["activationReadyModel"] = ( payload.get("currentModel") if loaded_entry @@ -2007,6 +2040,91 @@ def recover_model_switch(body: dict | None = Body(default=None), api_key: str = return value if isinstance(value, JSONResponse) else JSONResponse(value, headers={'Cache-Control': 'no-store'}) +def _external_model_observation_projection(value: Any) -> dict[str, Any]: + """Keep the browser response limited to a proved, nonsecret model identity.""" + if not isinstance(value, dict): + raise ValueError('External model observation is invalid') + model_id = value.get('modelId') + context_length = value.get('contextLength') + backend = value.get('backend') + if ( + value.get('status') != 'verified' + or not isinstance(model_id, str) + or re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+:/ @(),=-]{0,255}', model_id) is None + or type(context_length) is not int + or not 1 <= context_length <= 10_000_000 + or not isinstance(backend, str) + or re.fullmatch(r'[A-Za-z0-9._-]{1,64}', backend) is None + ): + raise ValueError('External model observation is invalid') + return { + 'status': 'verified', 'modelId': model_id, + 'contextLength': context_length, 'backend': backend, + } + + +@router.get('/api/models/external-observation') +def external_model_observation(api_key: str = Depends(verify_api_key)): + try: + value = request_agent_json('GET', '/v1/model/external-observation', timeout=20) + return JSONResponse(_external_model_observation_projection(value), headers={'Cache-Control': 'no-store'}) + except AgentHTTPError as exc: + if exc.status_code == 409: + raise HTTPException(status_code=409, detail='External Lemonade is not configured') from None + raise HTTPException(status_code=503, detail='External Lemonade identity is unavailable') from None + except (AgentClientError, ValueError): + raise HTTPException(status_code=503, detail='External Lemonade identity is unavailable') from None + + +@router.post('/api/models/external-adopt') +def adopt_external_model( + body: dict | None = Body(default=None), + api_key: str = Depends(verify_api_key), +): + model_id = body.get('model_id') if isinstance(body, dict) else None + if ( + not isinstance(body, dict) or set(body) != {'model_id'} + or not isinstance(model_id, str) + or re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+:/ @(),=-]{0,255}', model_id) is None + ): + raise HTTPException(status_code=400, detail='An exact model_id is required') + if pixel_stream_active(): + raise HTTPException(status_code=409, detail={ + 'code': 'pixel_chat_active', + 'message': 'Pixel is working. Stop the active response before adopting a model.', + }) + try: + value = request_agent_json( + 'POST', '/v1/model/external-adopt', payload={'model_id': model_id}, timeout=600, + ) + except AgentHTTPError as exc: + if exc.status_code in {400, 409, 503}: + detail = _agent_http_detail(exc) + if isinstance(detail, dict): + projected = {key: detail[key] for key in ('error', 'code', 'pending') if key in detail} + detail = projected or 'External model adoption was not confirmed' + else: + detail = 'External model adoption was not confirmed' + raise HTTPException(status_code=exc.status_code, detail=detail) from None + raise HTTPException(status_code=502, detail='External model adoption failed') from None + except AgentClientError: + raise HTTPException(status_code=503, detail='External model adoption was not confirmed; refresh recovery status') from None + if ( + not isinstance(value, dict) or value.get('status') != 'adopted' + or value.get('modelId') != model_id + or type(value.get('contextLength')) is not int + or not 16384 <= value['contextLength'] <= 10_000_000 + or not isinstance(value.get('modelTransactionId'), str) + or re.fullmatch(r'[a-f0-9]{64}', value['modelTransactionId']) is None + ): + raise HTTPException(status_code=502, detail='External model adoption response is invalid') + return JSONResponse({ + 'status': 'adopted', 'modelId': model_id, + 'contextLength': value.get('contextLength'), + 'modelTransactionId': value.get('modelTransactionId'), + }, headers={'Cache-Control': 'no-store'}) + + @router.post("/api/models/{model_id}/load") def load_model( model_id: str, @@ -2024,6 +2142,15 @@ def load_model( status_code=409, detail={**mode_denial, "requestedModelId": model_id}, ) + if _external_lemonade_runtime(): + raise HTTPException( + status_code=409, + detail={ + "error": "Externally managed Lemonade cannot use local model activation", + "code": "external_runtime_unmanaged", + "requestedModelId": model_id, + }, + ) model = _find_loadable_model(model_id) if model is None: diff --git a/ods/extensions/services/dashboard-api/routers/pixel.py b/ods/extensions/services/dashboard-api/routers/pixel.py index 1abd6f8912..4fc3eae7aa 100644 --- a/ods/extensions/services/dashboard-api/routers/pixel.py +++ b/ods/extensions/services/dashboard-api/routers/pixel.py @@ -12,8 +12,9 @@ import logging import os import re +import time from pathlib import Path -from typing import AsyncIterator, Literal +from typing import AsyncIterator, Callable, Literal from urllib.parse import urlparse import httpx @@ -26,6 +27,7 @@ from pixel_chat_results import ChatResultStore, ResultCapacity, ResultConflict, owner_namespace from security import verify_api_key from config import read_live_env_value +from helpers import get_loaded_model, get_llama_context_size from pixel_chat_identity import asks_display_name, confirmed_display_name, display_name_stream, messages_with_identity from pixel_chat_context import HistorySnapshot, public_context @@ -37,6 +39,8 @@ _MODEL = "pixel/default" _CHAT_STREAM_TIMEOUT_SECONDS = 2040.0 _CLIENT_DISCONNECT_POLL_SECONDS = 0.25 +_STREAM_KEEPALIVE_SECONDS = 15.0 +_STREAM_KEEPALIVE = b": pixel working\n\n" _CLIENT_CANCEL_TIMEOUT_SECONDS = 7.0 _MAX_KEY_LENGTH = 4096 _MAX_STATUS_BYTES = 64 * 1024 @@ -60,6 +64,10 @@ ) _CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]") _MODEL_SWITCH_DETAIL = "Model switch in progress; Pixel will be ready when activation completes" +_MODEL_IDENTITY_DETAIL = ( + "Pixel cannot verify its recorded model against the loaded Lemonade model. " + "Re-select the model in Models before using Pixel." +) _MODEL_ADAPTIVE_DETAIL = ( "Pixel is ready and adapts its tool flow for this model. Model capability " "affects the quality and persistence of complex work, not access or the " @@ -351,6 +359,8 @@ async def _local_inference_issue(host_status: object) -> str | None: def _model_readiness_issue_from_status(status: object) -> tuple[str, str] | None: + if isinstance(status, dict) and status.get("modelTransactionPending") is True: + return "model_switching", _MODEL_SWITCH_DETAIL switching = ( isinstance(status, dict) and status.get("activeOperation") == "model_activation" @@ -374,13 +384,13 @@ def _model_support_from_status(status: object) -> dict[str, str] | None: async def _model_readiness_issue() -> tuple[str, str] | None: - """Return a host-proven model transition, if present. + """Return a host-proven transition or an unverified Lemonade route. - A failed lifecycle probe does not falsely take down an otherwise healthy - Pixel edge. Model quality metadata is advisory; the edge readiness check - remains authoritative. + A failed host lifecycle probe alone does not take down the Pixel edge. + A recorded Lemonade route does require live identity proof before chat. + Model quality metadata remains advisory, not an access restriction. """ - return _model_readiness_issue_from_status(await _host_model_status()) + return await _model_readiness_issue_for_status(await _host_model_status()) def _active_runtime_projection(status: object) -> dict[str, object] | None: @@ -398,6 +408,21 @@ def _active_runtime_projection(status: object) -> dict[str, object] | None: ): return {key: runtime[key] for key in expected} return None + if isinstance(runtime, dict) and runtime.get("source") == "external-host": + expected = {"source", "model"} + if ( + expected <= set(runtime) <= expected | {"contextLength"} + and isinstance(runtime.get("model"), str) + and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/+:-]{0,255}", runtime["model"]) + and "://" not in runtime["model"] + and ( + "contextLength" not in runtime + or type(runtime["contextLength"]) is int + and 1 <= runtime["contextLength"] <= 10_000_000 + ) + ): + return {key: runtime[key] for key in expected | {"contextLength"} if key in runtime} + return None expected = {"source", "model", "contextLength", "maxTokens", "reasoning"} if ( not isinstance(runtime, dict) @@ -419,6 +444,77 @@ def _active_runtime_projection(status: object) -> dict[str, object] | None: return {key: runtime[key] for key in expected | {"routeFingerprint"} if key in runtime} +async def _verified_external_host_runtime(host_status: object) -> dict[str, object] | None: + """Identify a fixed external model from a live probe, never .env alone. + + This is a status identity, not a model-switch or agent-quality proof. Do not + expose the configured origin, credentials, or provider response body. + """ + if ( + not isinstance(host_status, dict) + or host_status.get("activeRuntime") is not None + or os.environ.get("LLM_BACKEND", "").strip().casefold() != "external" + or read_live_env_value("LLM_BACKEND").strip().casefold() != "external" + or read_live_env_value("ODS_MODEL_SWITCHBOARD").strip().casefold() != "observe" + or read_live_env_value("EXTERNAL_LLM_PROVIDER").strip().casefold() != "openai-compatible" + ): + return None + expected = read_live_env_value("EXTERNAL_LLM_MODEL").strip() + if ( + re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/+:-]{0,255}", expected) is None + or "://" in expected + ): + return None + try: + loaded = await asyncio.wait_for(get_loaded_model(), timeout=3.0) + except (asyncio.TimeoutError, httpx.HTTPError, OSError, ValueError, TypeError): + return None + if loaded != expected: + return None + runtime: dict[str, object] = {"source": "external-host", "model": loaded} + try: + context = await asyncio.wait_for(get_llama_context_size(loaded), timeout=3.0) + except (asyncio.TimeoutError, httpx.HTTPError, OSError, ValueError, TypeError): + context = None + if type(context) is int and 1 <= context <= 10_000_000: + runtime["contextLength"] = context + return _active_runtime_projection({"activeRuntime": runtime}) + + +def _model_identity_tokens(value: str | None) -> set[str]: + """Compare a Lemonade ID with the equivalent GGUF basename, not a path.""" + if not isinstance(value, str) or not value.strip(): + return set() + name = Path(value.strip()).name.casefold() + tokens = {name} + if name.startswith("extra."): + tokens.add(name[6:]) + for token in tuple(tokens): + if token.endswith(".gguf"): + tokens.add(token[:-5]) + return tokens + + +async def _model_readiness_issue_for_status(status: object) -> tuple[str, str] | None: + issue = _model_readiness_issue_from_status(status) + if issue is not None: + return issue + runtime = _active_runtime_projection(status) + if (runtime is None or runtime.get("source") != "local-switchboard" + or read_live_env_value("LLM_BACKEND").strip().casefold() != "lemonade"): + return None + try: + loaded = await asyncio.wait_for(get_loaded_model(), timeout=3.0) + except Exception as exc: + # Probe failures cannot validate a recorded external route. Do not log + # exception text; it may contain the private backend origin or key. + logger.warning("Pixel Lemonade identity probe failed (%s)", type(exc).__name__) + return "model_unavailable", _MODEL_IDENTITY_DETAIL + if not (_model_identity_tokens(runtime["model"]) & _model_identity_tokens(loaded)): + return "model_unavailable", _MODEL_IDENTITY_DETAIL + return None + + async def _model_activation_in_progress() -> bool: """Compatibility wrapper retained for focused lifecycle callers/tests.""" issue = await _model_readiness_issue() @@ -443,7 +539,7 @@ async def pixel_status() -> dict[str, object]: if config is None: return {"available": False, "model": None, "detail": "Pixel is not enabled"} host_status = await _host_model_status() - readiness_issue = _model_readiness_issue_from_status(host_status) + readiness_issue = await _model_readiness_issue_for_status(host_status) if readiness_issue is not None: state, detail = readiness_issue return { @@ -481,6 +577,8 @@ async def pixel_status() -> dict[str, object]: if inference_issue: return {"available": False, "model": None, "state": "model_unavailable", "detail": inference_issue} runtime = _active_runtime_projection(host_status) + if available and runtime is None: + runtime = await _verified_external_host_runtime(host_status) if available and runtime is not None: result["runtime"] = runtime model_support = _model_support_from_status(host_status) @@ -710,6 +808,7 @@ def release(finished): async def subscribe(): after = -1 + last_sent = time.monotonic() while True: # Snapshot terminal state before yielding any bytes. Sending a chunk # can suspend this subscriber while the producer commits its tail. @@ -718,10 +817,17 @@ async def subscribe(): for chunk in store.chunks(identity, after): after = chunk["sequence"] yield chunk["data"] + last_sent = time.monotonic() if row is None or row["state"] != "active": return if await request.is_disconnected(): return + if time.monotonic() - last_sent >= _STREAM_KEEPALIVE_SECONDS: + # A CPU-backed local model can spend minutes in prompt prefill. + # Keep the subscriber alive without inventing an answer or + # persisting transport-only comments in the result receipt. + yield _STREAM_KEEPALIVE + last_sent = time.monotonic() # Subscriber disposal never cancels the independent bounded producer. await asyncio.sleep(_CLIENT_DISCONNECT_POLL_SECONDS) @@ -850,10 +956,12 @@ def _edge_chat_body(body, messages): async def _iter_upstream_chunks( upstream: httpx.Response, request: Request, + can_emit_keepalive: Callable[[], bool], ) -> AsyncIterator[bytes]: """Yield upstream bytes while promptly observing a silent client exit.""" iterator = upstream.aiter_bytes().__aiter__() pending: asyncio.Task[bytes] | None = None + last_sent = time.monotonic() try: while True: pending = asyncio.create_task(anext(iterator)) @@ -866,12 +974,19 @@ async def _iter_upstream_chunks( break if await request.is_disconnected(): raise _ClientDisconnected + # A comment is safe only between complete SSE lines. The + # caller may be holding an upstream fragment without a newline; + # injecting a comment there would corrupt that data line. + if can_emit_keepalive() and time.monotonic() - last_sent >= _STREAM_KEEPALIVE_SECONDS: + yield _STREAM_KEEPALIVE + last_sent = time.monotonic() try: chunk = pending.result() except StopAsyncIteration: return pending = None yield chunk + last_sent = time.monotonic() finally: if pending is not None and not pending.done(): pending.cancel() @@ -977,7 +1092,7 @@ async def stream() -> AsyncIterator[bytes]: try: async with async_timeout(_CHAT_STREAM_TIMEOUT_SECONDS): buffered = bytearray() - async for chunk in _iter_upstream_chunks(upstream, request): + async for chunk in _iter_upstream_chunks(upstream, request, lambda: not buffered): buffered.extend(chunk) while True: newline = buffered.find(b"\n") @@ -1028,4 +1143,3 @@ async def __call__(self, scope, receive, send): "X-Accel-Buffering": "no", }, ) - diff --git a/ods/extensions/services/dashboard-api/tests/test_config.py b/ods/extensions/services/dashboard-api/tests/test_config.py index aeb35b49e2..50f8128b34 100644 --- a/ods/extensions/services/dashboard-api/tests/test_config.py +++ b/ods/extensions/services/dashboard-api/tests/test_config.py @@ -1,5 +1,6 @@ """Tests for config.py — manifest loading and service discovery.""" +import json import logging from pathlib import Path @@ -41,7 +42,7 @@ def test_bundled_llama_server_is_discoverable_on_cpu_fallback(): assert all("cpu" in feature["gpu_backends"] for feature in manifest["features"]) -def test_aider_library_extension_is_discoverable_on_cpu_fallback(): +def test_aider_library_extension_is_discoverable_on_cpu_fallback(tmp_path): manifest_path = ( Path(__file__).resolve().parents[3] / "library" @@ -51,7 +52,22 @@ def test_aider_library_extension_is_discoverable_on_cpu_fallback(): ) manifest = config.yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + assert "cpu" in manifest["service"]["gpu_backends"] assert "none" in manifest["service"]["gpu_backends"] + assert all("cpu" in feature["gpu_backends"] for feature in manifest["features"]) + catalog_path = Path(__file__).resolve().parents[4] / "config" / "extensions-catalog.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + aider = next(ext for ext in catalog["extensions"] if ext["id"] == "aider") + assert {"cpu", "none"}.issubset(aider["gpu_backends"]) + + installed = tmp_path / "aider" + installed.mkdir() + (installed / "manifest.yaml").write_text(manifest_path.read_text(encoding="utf-8")) + (installed / "compose.yaml").write_text("services:\n aider:\n image: test/aider\n") + services, features, errors = load_extension_manifests(tmp_path, "cpu") + assert errors == [] + assert "aider" in services + assert any(feature["id"] == "ai-pair-programming" for feature in features) def test_manifest_loader_rejects_pathological_nesting(tmp_path): diff --git a/ods/extensions/services/dashboard-api/tests/test_extensions.py b/ods/extensions/services/dashboard-api/tests/test_extensions.py index ce72c6ae30..6021d87b31 100644 --- a/ods/extensions/services/dashboard-api/tests/test_extensions.py +++ b/ods/extensions/services/dashboard-api/tests/test_extensions.py @@ -188,6 +188,26 @@ def test_catalog_gpu_compatible_filter(self, test_client, monkeypatch, tmp_path) assert "compat" in ids assert "incompat" not in ids + def test_aider_is_visible_and_installable_on_cpu_fallback(self, test_client, monkeypatch, tmp_path): + """The CPU fallback must not hide Aider's zero-VRAM CLI card.""" + catalog_path = Path(__file__).resolve().parents[4] / "config" / "extensions-catalog.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + aider = next(ext for ext in catalog["extensions"] if ext["id"] == "aider") + gpu_only = _make_catalog_ext("gpu-only", "GPU only", gpu_backends=["nvidia"]) + _patch_extensions_config(monkeypatch, [aider, gpu_only], gpu_backend="cpu", tmp_path=tmp_path) + library_dir = tmp_path / "lib" / "aider" + library_dir.mkdir(parents=True) + (library_dir / "compose.yaml").write_text("services:\n aider:\n image: test/aider\n") + + with patch("helpers.get_all_services", new_callable=AsyncMock, return_value=[]): + resp = test_client.get("/api/extensions/catalog", headers=test_client.auth_headers) + + assert resp.status_code == 200 + by_id = {ext["id"]: ext for ext in resp.json()["extensions"]} + assert by_id["aider"]["status"] == "not_installed" + assert by_id["aider"]["installable"] is True + assert by_id["gpu-only"]["status"] == "incompatible" + def test_catalog_summary_counts(self, test_client, monkeypatch, tmp_path): """Summary counts correctly reflect extension statuses.""" catalog = [ diff --git a/ods/extensions/services/dashboard-api/tests/test_external_adopt_api.py b/ods/extensions/services/dashboard-api/tests/test_external_adopt_api.py new file mode 100644 index 0000000000..03cdfccf96 --- /dev/null +++ b/ods/extensions/services/dashboard-api/tests/test_external_adopt_api.py @@ -0,0 +1,124 @@ +import json +from unittest.mock import Mock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from host_agent_client import AgentHTTPError, AgentTimeout +from routers import models + + +HEADERS = {'Authorization': 'Bearer test-key-12345'} +OBSERVED = { + 'status': 'verified', 'modelId': 'Qwen3.5-2B-Q4_K_M', + 'contextLength': 65536, 'backend': 'vulkan', +} + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setattr(models, 'pixel_stream_active', lambda: False) + app = FastAPI() + app.include_router(models.router) + with TestClient(app) as value: + yield value + + +def test_external_observation_and_adoption_require_owner_auth(client, monkeypatch): + call = Mock() + monkeypatch.setattr(models, 'request_agent_json', call) + assert client.get('/api/models/external-observation').status_code == 401 + assert client.post('/api/models/external-adopt', json={'model_id': OBSERVED['modelId']}).status_code == 401 + call.assert_not_called() + + +def test_external_observation_projects_only_verified_nonsecret_identity(client, monkeypatch): + call = Mock(return_value={**OBSERVED, 'checkpoint': 'C:/private/model.gguf', 'secret': 'hidden'}) + monkeypatch.setattr(models, 'request_agent_json', call) + result = client.get('/api/models/external-observation', headers=HEADERS) + assert result.status_code == 200 + assert result.json() == OBSERVED + assert result.headers['cache-control'] == 'no-store' + call.assert_called_once_with('GET', '/v1/model/external-observation', timeout=20) + + +@pytest.mark.parametrize('value', [ + {**OBSERVED, 'status': 'guessed'}, + {**OBSERVED, 'contextLength': 0}, + {**OBSERVED, 'modelId': 'model\nsecret'}, +]) +def test_external_observation_rejects_unproved_agent_response(client, monkeypatch, value): + monkeypatch.setattr(models, 'request_agent_json', Mock(return_value=value)) + assert client.get('/api/models/external-observation', headers=HEADERS).status_code == 503 + + +@pytest.mark.parametrize('body', [None, {}, {'model_id': 'bad\nvalue'}, + {'model_id': OBSERVED['modelId'], 'checkpoint': '/private/file'}]) +def test_external_adoption_accepts_only_exact_model_identity(client, monkeypatch, body): + call = Mock() + monkeypatch.setattr(models, 'request_agent_json', call) + assert client.post('/api/models/external-adopt', headers=HEADERS, json=body).status_code == 400 + call.assert_not_called() + + +def test_external_adoption_submits_once_and_projects_receipt(client, monkeypatch): + call = Mock(return_value={ + 'status': 'adopted', 'modelId': OBSERVED['modelId'], + 'contextLength': 65536, 'modelTransactionId': 'a' * 64, + 'private': 'hidden', + }) + monkeypatch.setattr(models, 'request_agent_json', call) + result = client.post('/api/models/external-adopt', headers=HEADERS, json={'model_id': OBSERVED['modelId']}) + assert result.status_code == 200 + assert result.json() == { + 'status': 'adopted', 'modelId': OBSERVED['modelId'], + 'contextLength': 65536, 'modelTransactionId': 'a' * 64, + } + assert result.headers['cache-control'] == 'no-store' + call.assert_called_once_with('POST', '/v1/model/external-adopt', + payload={'model_id': OBSERVED['modelId']}, timeout=600) + + +@pytest.mark.parametrize('damage', [ + {'contextLength': 0}, {'modelTransactionId': 'unproved'}, {'modelId': 'other-model'}, +]) +def test_external_adoption_rejects_unproved_agent_receipt(client, monkeypatch, damage): + value = {'status': 'adopted', 'modelId': OBSERVED['modelId'], + 'contextLength': 65536, 'modelTransactionId': 'a' * 64} + value.update(damage) + monkeypatch.setattr(models, 'request_agent_json', Mock(return_value=value)) + result = client.post('/api/models/external-adopt', headers=HEADERS, json={'model_id': OBSERVED['modelId']}) + assert result.status_code == 502 + + +def test_external_adoption_preserves_pending_without_replay(client, monkeypatch): + pending = {'error': 'repair required', 'code': 'managed_model_recovery_required', + 'pending': True, 'secret': 'hidden'} + call = Mock(side_effect=AgentHTTPError(503, 'unconfirmed', json.dumps(pending))) + monkeypatch.setattr(models, 'request_agent_json', call) + result = client.post('/api/models/external-adopt', headers=HEADERS, json={'model_id': OBSERVED['modelId']}) + assert result.status_code == 503 + assert result.json()['detail'] == { + 'error': 'repair required', 'code': 'managed_model_recovery_required', 'pending': True, + } + assert call.call_count == 1 + + +def test_external_adoption_timeout_is_not_retried(client, monkeypatch): + call = Mock(side_effect=AgentTimeout('private detail')) + monkeypatch.setattr(models, 'request_agent_json', call) + result = client.post('/api/models/external-adopt', headers=HEADERS, json={'model_id': OBSERVED['modelId']}) + assert result.status_code == 503 + assert 'private detail' not in result.text + assert call.call_count == 1 + + +def test_external_adoption_refuses_active_pixel_stream(client, monkeypatch): + call = Mock() + monkeypatch.setattr(models, 'request_agent_json', call) + monkeypatch.setattr(models, 'pixel_stream_active', lambda: True) + result = client.post('/api/models/external-adopt', headers=HEADERS, json={'model_id': OBSERVED['modelId']}) + assert result.status_code == 409 + assert result.json()['detail']['code'] == 'pixel_chat_active' + call.assert_not_called() diff --git a/ods/extensions/services/dashboard-api/tests/test_host_agent.py b/ods/extensions/services/dashboard-api/tests/test_host_agent.py index d87b0556e7..09fa804900 100644 --- a/ods/extensions/services/dashboard-api/tests/test_host_agent.py +++ b/ods/extensions/services/dashboard-api/tests/test_host_agent.py @@ -364,6 +364,34 @@ def test_linux_prefers_ods_network_gateway(self, monkeypatch): monkeypatch.setattr(_mod, "_detect_docker_bridge_gateway", lambda: "172.17.0.1") assert _resolve_agent_bind_addr({}, "Linux") == "172.18.0.1" + assert _resolve_agent_bind_addr({}, "Linux", require_ods_network=True) == "172.18.0.1" + + def test_managed_linux_refuses_boot_race_bridge_fallback(self, monkeypatch): + monkeypatch.setattr(_mod, "_running_under_wsl", lambda *_args, **_kwargs: False) + monkeypatch.setattr(_mod, "_detect_docker_network_gateway", lambda network: "") + monkeypatch.setattr(_mod, "_detect_docker_bridge_gateway", lambda: "172.17.0.1") + + with pytest.raises(RuntimeError, match="ods-network is unavailable"): + _resolve_agent_bind_addr({}, "Linux", require_ods_network=True) + + # An explicit operator bind remains an intentional override. + assert _resolve_agent_bind_addr( + {"ODS_AGENT_BIND": "127.0.0.1"}, "Linux", require_ods_network=True + ) == "127.0.0.1" + + def test_managed_wsl_keeps_its_local_bridge_contract(self, monkeypatch): + monkeypatch.setattr(_mod, "_running_under_wsl", lambda *_args, **_kwargs: True) + monkeypatch.setattr(_mod, "_detect_docker_bridge_gateway", lambda: "172.17.0.1") + monkeypatch.setattr(_mod, "_local_bind_address_available", lambda address: address == "172.17.0.1") + + assert _resolve_agent_bind_addr({}, "Linux", require_ods_network=True) == "172.17.0.1" + + def test_systemd_unit_retries_until_scoped_network_exists(self): + unit = (_agent_path.parents[1] / "scripts/systemd/ods-host-agent.service").read_text( + encoding="utf-8" + ) + assert "--require-ods-network" in unit + assert "StartLimitIntervalSec=0" in unit def test_wsl_native_docker_uses_locally_owned_bridge_gateway(self, monkeypatch): monkeypatch.setattr(_mod, "_running_under_wsl", lambda *_args, **_kwargs: True) @@ -4490,7 +4518,10 @@ def test_model_status_projects_only_active_agent_viability( assert handler.response_code == 200 response = handler.parse_response() - assert response == {"status": "idle", "activeAgentViable": False} + assert response == { + "status": "idle", "activeAgentViable": False, + "modelTransactionPending": False, + } assert "runtimeModelId" not in response assert "capabilities" not in response @@ -4519,6 +4550,7 @@ def test_model_status_projects_active_remote_runtime_over_local_rollback( "status": "idle", "activeAgentViable": True, "activeRuntime": {"source": "remote-provider", **runtime}, + "modelTransactionPending": False, } def test_model_status_applies_new_pixel_specific_revocation( @@ -4631,7 +4663,7 @@ def test_switchboard_route_requires_reproof_when_context_changes( assert _mod._switchboard_state_needs_current_env_verification(state_path) is True payload = {"status": "idle"} _mod._project_switchboard_agent_viability(payload) - assert payload == {"status": "idle"} + assert payload == {"status": "idle", "modelTransactionPending": False} @pytest.mark.parametrize("agent_viable", [True, False]) @pytest.mark.parametrize("backend", ["llama-server", "lemonade"]) @@ -7300,7 +7332,9 @@ def readiness(*_args, **kwargs): _mod.AgentHandler._handle_model_status(handler) assert handler.response_code == 200 - assert handler.parse_response() == {"status": "idle"} + assert handler.parse_response() == { + "status": "idle", "modelTransactionPending": False, + } assert readiness_calls == [] assert scheduled == ["model-status"] doc = json.loads(state_path.read_text(encoding="utf-8")) @@ -7340,7 +7374,9 @@ def test_model_status_schedules_route_proof_while_bootstrap_swap_is_verifying( _mod.AgentHandler._handle_model_status(handler) assert handler.response_code == 200 - assert handler.parse_response() == {"status": "idle"} + assert handler.parse_response() == { + "status": "idle", "modelTransactionPending": False, + } assert scheduled == ["model-status"] def test_empty_finished_download_is_failed_not_complete(self, tmp_path, monkeypatch): diff --git a/ods/extensions/services/dashboard-api/tests/test_model_activate.py b/ods/extensions/services/dashboard-api/tests/test_model_activate.py index f6c3225f1d..eded7d4e33 100644 --- a/ods/extensions/services/dashboard-api/tests/test_model_activate.py +++ b/ods/extensions/services/dashboard-api/tests/test_model_activate.py @@ -245,6 +245,414 @@ def test_external_lemonade_catalog_does_not_fall_back_to_stale_local_model( assert model == {} +def test_external_lemonade_local_activation_rejects_before_mutation( + monkeypatch, tmp_path, +): + install = tmp_path / "ods" + install.mkdir() + env_path = install / ".env" + original = ( + "ODS_MODE=lemonade\nLLM_BACKEND=lemonade\nLEMONADE_EXTERNAL=true\n" + "LEMONADE_MODEL=Qwen3.6-35B-A3B-GGUF\n" + ) + env_path.write_text(original, encoding="utf-8") + monkeypatch.setattr(_mod, "INSTALL_DIR", install) + monkeypatch.setattr(_mod, "STARTUP_ODS_MODE", "lemonade") + monkeypatch.setattr( + _mod, "_load_model_library_records", + lambda: pytest.fail("external runtime must be rejected before model lookup"), + ) + monkeypatch.setattr( + _mod, "_recreate_llama_server", + lambda *_args, **_kwargs: pytest.fail("external runtime must not be recreated"), + ) + handler = _ResponseHandler() + _mod.AgentHandler._do_model_activate(handler, "Qwen3.5-2B-Q4_K_M") + assert handler.response_code == 409 + assert handler.parse_response()["code"] == "external_runtime_unmanaged" + assert env_path.read_text(encoding="utf-8") == original + + +@pytest.mark.parametrize("recovery", [ + {"pending": True}, + RuntimeError("unsafe journal"), +]) +def test_host_model_status_marks_uncertain_native_transaction_pending( + monkeypatch, recovery, +): + monkeypatch.setattr(_mod, "_active_remote_provider_pixel_runtime", lambda: None) + monkeypatch.setattr(_mod, "_switchboard_state", None) + + def status(): + if isinstance(recovery, Exception): + raise recovery + return recovery + + monkeypatch.setattr(_mod, "_pixel_model_recovery_status", status) + payload = {} + _mod._project_switchboard_agent_viability(payload) + assert payload["modelTransactionPending"] is True + + +def _external_lemonade_observation_fixture(): + checkpoint = "unsloth/Qwen3.6-35B-A3B-GGUF:Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf" + health = { + "status": "ok", + "model_loaded": "Qwen3.6-35B-A3B-GGUF", + "all_models_loaded": [{ + "type": "llm", + "model_name": "Qwen3.6-35B-A3B-GGUF", + "recipe": "llamacpp", + "checkpoint": checkpoint, + "recipe_options": {"ctx_size": 65536, "llamacpp_backend": "vulkan"}, + }], + } + catalog = {"data": [{ + "id": "Qwen3.6-35B-A3B-GGUF", + "downloaded": True, + "recipe": "llamacpp", + "checkpoint": checkpoint, + }]} + return health, catalog + + +def test_external_lemonade_observation_requires_exact_live_checkpoint(): + health, catalog = _external_lemonade_observation_fixture() + assert _mod._verified_external_lemonade_observation(health, catalog) == { + "modelId": "Qwen3.6-35B-A3B-GGUF", + "checkpoint": catalog["data"][0]["checkpoint"], + "contextLength": 65536, + "backend": "vulkan", + } + + +@pytest.mark.parametrize("damage", [ + lambda health, catalog: health.update(status="loading"), + lambda health, catalog: health.update(model_loaded="another-model"), + lambda health, catalog: health["all_models_loaded"].append( + dict(health["all_models_loaded"][0]) + ), + lambda health, catalog: health["all_models_loaded"][0]["recipe_options"].update( + ctx_size=True + ), + lambda health, catalog: catalog["data"][0].update(downloaded=False), + lambda health, catalog: catalog["data"][0].update(checkpoint="different.gguf"), + lambda health, catalog: catalog["data"].append(dict(catalog["data"][0])), +]) +def test_external_lemonade_observation_fails_closed_on_ambiguous_evidence(damage): + health, catalog = _external_lemonade_observation_fixture() + damage(health, catalog) + with pytest.raises(ValueError, match="External Lemonade"): + _mod._verified_external_lemonade_observation(health, catalog) + + +def test_external_lemonade_observation_endpoint_is_authenticated_and_redacted(monkeypatch): + monkeypatch.setattr(_mod, "AGENT_API_KEY", "observation-test-key") + monkeypatch.setattr(_mod, "load_env", lambda _path: {"LEMONADE_EXTERNAL": "true"}) + monkeypatch.setattr(_mod, "_read_external_lemonade_observation", lambda _env: { + "modelId": "Qwen3.6-35B-A3B-GGUF", + "checkpoint": "private-checkpoint-path.gguf", + "contextLength": 65536, + "backend": "vulkan", + }) + server = _mod.ThreadedHTTPServer(("127.0.0.1", 0), _mod.AgentHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=5) + connection.request("GET", "/v1/model/external-observation") + denied = connection.getresponse() + assert denied.status == 401 + denied.read() + connection.request("GET", "/v1/model/external-observation", headers={ + "Authorization": "Bearer observation-test-key", + }) + response = connection.getresponse() + assert response.status == 200 + assert json.loads(response.read()) == { + "status": "verified", + "modelId": "Qwen3.6-35B-A3B-GGUF", + "contextLength": 65536, + "backend": "vulkan", + } + connection.close() + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def test_external_adoption_endpoint_requires_auth_and_preserves_pending_hold(monkeypatch): + monkeypatch.setattr(_mod, "AGENT_API_KEY", "adoption-test-key") + actions = [] + monkeypatch.setattr(_mod, "_begin_model_activation", lambda model: ( + actions.append(("begin", model)) or (True, None) + )) + monkeypatch.setattr(_mod, "_end_model_activation", lambda: actions.append(("end", None))) + monkeypatch.setattr(_mod, "_adopt_external_lemonade_model", lambda _model: ( + (_ for _ in ()).throw(_mod._PixelModelTransactionUncertain("private detail")) + )) + monkeypatch.setattr(_mod, "_pixel_model_recovery_status", lambda: {"pending": True}) + server = _mod.ThreadedHTTPServer(("127.0.0.1", 0), _mod.AgentHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=5) + body = json.dumps({"model_id": "loaded-B"}) + headers = {"Content-Type": "application/json"} + connection.request("POST", "/v1/model/external-adopt", body=body, headers=headers) + denied = connection.getresponse() + assert denied.status == 401 + denied.read() + connection.request("POST", "/v1/model/external-adopt", body=body, headers={ + **headers, "Authorization": "Bearer adoption-test-key", + }) + response = connection.getresponse() + payload = json.loads(response.read()) + assert response.status == 503 + assert payload["code"] == "managed_model_recovery_required" + assert payload["pending"] is True + assert "private detail" not in json.dumps(payload) + assert actions == [("begin", "loaded-B"), ("end", None)] + monkeypatch.setattr(_mod, "_adopt_external_lemonade_model", lambda _model: ( + (_ for _ in ()).throw(_mod._ExternalAdoptionReceiptUnavailable("private detail")) + )) + connection.request("POST", "/v1/model/external-adopt", body=body, headers={ + **headers, "Authorization": "Bearer adoption-test-key", + }) + receipt_response = connection.getresponse() + receipt_payload = json.loads(receipt_response.read()) + assert receipt_response.status == 503 + assert receipt_payload["code"] == "external_adoption_receipt_unavailable" + assert receipt_payload["pending"] is False + assert "private detail" not in json.dumps(receipt_payload) + connection.close() + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def test_external_lemonade_observation_rechecks_health_after_catalog(monkeypatch): + health, catalog = _external_lemonade_observation_fixture() + changed = json.loads(json.dumps(health)) + changed["model_loaded"] = "another-model" + responses = iter([health, catalog, changed]) + requested = [] + + class _Opener: + def open(self, request, timeout): + requested.append((request.full_url, timeout)) + return io.BytesIO(json.dumps(next(responses)).encode("utf-8")) + + monkeypatch.setattr(_mod.urllib_request, "build_opener", lambda *_args: _Opener()) + with pytest.raises(ValueError, match="External Lemonade"): + _mod._read_external_lemonade_observation({ + "LEMONADE_EXTERNAL": "true", + "LEMONADE_BASE_URL": "http://127.0.0.1:8080", + }) + assert requested == [ + ("http://127.0.0.1:8080/api/v1/health", 5), + ("http://127.0.0.1:8080/api/v1/models", 5), + ("http://127.0.0.1:8080/api/v1/health", 5), + ] + + +@pytest.mark.parametrize(("persisted_model", "persisted_context", "live_model", "live_context", "proven"), [ + ("Qwen3.5-2B-Q4_K_M", "65536", "Qwen3.5-2B-Q4_K_M", 65536, True), + ("Qwen3.6-35B-A3B-GGUF", "65536", "Qwen3.5-2B-Q4_K_M", 65536, False), + ("Qwen3.5-2B-Q4_K_M", "65536", "Qwen3.5-2B-Q4_K_M", 32768, False), + ("Qwen3.5-2B-Q4_K_M", "32768", "Qwen3.5-2B-Q4_K_M", 65536, False), +]) +def test_external_pixel_recovery_proves_physical_and_persisted_model( + monkeypatch, persisted_model, persisted_context, live_model, live_context, proven, +): + config = { + "LEMONADE_EXTERNAL": "true", + "LEMONADE_MODEL": persisted_model, + "CTX_SIZE": persisted_context, + "MAX_CONTEXT": persisted_context, + # The installer's local GGUF is unrelated to native Lemonade. + "GGUF_FILE": "Qwen3.5-9B-Q4_K_M.gguf", + } + monkeypatch.setattr(_mod, "_read_external_lemonade_observation", lambda _env: { + "modelId": live_model, "contextLength": live_context, + }) + monkeypatch.setattr(_mod, "_wait_for_model_readiness", lambda *_args, **_kwargs: ( + pytest.fail("stale local GGUF must not prove external recovery") + )) + assert _mod._prove_pixel_model_contract(config, { + "model": "Qwen3.5-2B-Q4_K_M", "contextLength": 65536, + }) is proven + + +@pytest.mark.parametrize("failure", [None, "litellm", "receipt"]) +def test_external_adoption_converges_consumers_without_touching_native_runtime( + monkeypatch, tmp_path, failure, +): + install = tmp_path / "ods" + install.mkdir() + env_path = install / ".env" + env_path.write_text( + "ODS_MODE=lemonade\nLEMONADE_EXTERNAL=true\nPIXEL_OPENWEBUI_KEY=test-key\n" + "LEMONADE_MODEL=Qwen3.6-35B-A3B-GGUF\nLLM_MODEL=old\n" + "GGUF_FILE=old.gguf\nCTX_SIZE=65536\nMAX_CONTEXT=65536\n", + encoding="utf-8", + ) + monkeypatch.setattr(_mod, "INSTALL_DIR", install) + events = [] + loaded = { + "modelId": "Qwen3.5-2B-Q4_K_M", "checkpoint": "Qwen3.5-2B-Q4_K_M.gguf", + "contextLength": 65536, "backend": "vulkan", + } + monkeypatch.setattr(_mod, "_read_external_lemonade_observation", lambda _env: ( + events.append("observe") or dict(loaded) + )) + monkeypatch.setattr(_mod, "_switchboard_state", object()) + monkeypatch.setattr(_mod, "_capture_hermes_live_config", lambda _path: { + "exists": False, "source": "absent", + }) + monkeypatch.setattr(_mod, "_capture_opencode_config", lambda: {"files": {}}) + monkeypatch.setattr(_mod, "_capture_managed_opencode_state", lambda: { + "active": True, "system": "Linux", + }) + monkeypatch.setattr(_mod, "_capture_container_state", lambda _name: { + "exists": True, "running": True, + }) + monkeypatch.setattr(_mod, "_capture_perplexica_config", lambda *_args: {"values": {}}) + + class Transaction: + id = "a" * 64 + target = None + journal = {"phase": "held"} + + def _save(self, phase): + events.append(("journal", phase, self.target["model"])) + + def apply(self, target): + events.append(("pixel-apply", target["model"])) + self.journal["phase"] = "applied" + return "reconciled" + + def finish(self, outcome): + events.append(("pixel-finish", outcome)) + + monkeypatch.setattr(_mod, "_begin_or_resume_external_pixel_transaction", lambda _env, _target: Transaction()) + monkeypatch.setattr(_mod, "_external_adoption_route_published", lambda *_args: False) + for name in ( + "_write_lemonade_config", "_render_model_router_runtime_configs", + "_patch_hermes_model_config", "_update_opencode_config", + "_update_perplexica_model", "_wait_for_container_health", + "_verify_litellm_route", "_verify_running_hermes_route", + "_verify_openclaw_model_env", "_restart_managed_opencode", + ): + monkeypatch.setattr(_mod, name, lambda *_args, _name=name, **_kwargs: ( + events.append(_name) or True + )) + if failure == "litellm": + def fail_route(_env): + events.append("_verify_litellm_route") + raise RuntimeError("LiteLLM route unavailable") + monkeypatch.setattr(_mod, "_verify_litellm_route", fail_route) + monkeypatch.setattr(_mod, "_restart_existing_container", lambda name, *_args, **_kwargs: ( + events.append(("restart", name)) or True + )) + monkeypatch.setattr(_mod, "_recreate_openclaw_if_present", lambda *_args: ( + events.append("openclaw") or True + )) + monkeypatch.setattr(_mod, "_publish_activation_route", lambda *_args: ( + events.append("route") or {} + )) + def write_receipt(*_args): + events.append("receipt") + if failure == "receipt": + raise OSError("receipt unavailable") + monkeypatch.setattr(_mod, "_atomic_write_json", write_receipt) + monkeypatch.setattr(_mod, "_recreate_llama_server", lambda *_args, **_kwargs: ( + pytest.fail("external adoption must never recreate native inference") + )) + monkeypatch.setattr(_mod, "_restart_windows_lemonade", lambda *_args: ( + pytest.fail("external adoption must never restart native Lemonade") + )) + + if failure == "litellm": + with pytest.raises(_mod._PixelModelTransactionUncertain): + _mod._adopt_external_lemonade_model(loaded["modelId"]) + assert not any(isinstance(event, tuple) and event[0] == "pixel-finish" for event in events) + # A failed alias probe leaves a forward-only, held transaction. The + # proved B route must already be published so the probe cannot cause + # Lemonade to auto-load the previous A model. + assert events.index("route") < events.index("_verify_litellm_route") + assert _mod.load_env(env_path)["LEMONADE_MODEL"] == loaded["modelId"] + return + if failure == "receipt": + with pytest.raises(_mod._ExternalAdoptionReceiptUnavailable): + _mod._adopt_external_lemonade_model(loaded["modelId"]) + assert events.index(("pixel-finish", "commit")) < events.index("receipt") + return + result = _mod._adopt_external_lemonade_model(loaded["modelId"]) + assert result["status"] == "adopted" + persisted = _mod.load_env(env_path) + assert persisted["LEMONADE_MODEL"] == loaded["modelId"] + assert persisted["CTX_SIZE"] == persisted["MAX_CONTEXT"] == "65536" + assert events.index("route") < events.index("_verify_litellm_route") + assert events.index("route") < events.index(("pixel-apply", loaded["modelId"])) + assert events.index(("pixel-finish", "commit")) < events.index("receipt") + assert events[-1] == "receipt" + for consumer in ( + "_write_lemonade_config", "_render_model_router_runtime_configs", + "_update_opencode_config", "_update_perplexica_model", "openclaw", + ): + assert consumer in events + + +def test_external_adoption_rejects_unobserved_target_without_writes(monkeypatch, tmp_path): + install = tmp_path / "ods" + install.mkdir() + env_path = install / ".env" + original = "LEMONADE_EXTERNAL=true\nLEMONADE_MODEL=old\nCTX_SIZE=65536\n" + env_path.write_text(original, encoding="utf-8") + monkeypatch.setattr(_mod, "INSTALL_DIR", install) + monkeypatch.setattr(_mod, "_read_external_lemonade_observation", lambda _env: { + "modelId": "loaded-B", "checkpoint": "B.gguf", + "contextLength": 65536, "backend": "vulkan", + }) + monkeypatch.setattr(_mod, "_begin_or_resume_external_pixel_transaction", lambda *_args: ( + pytest.fail("mismatched target must not create a transaction") + )) + with pytest.raises(ValueError, match="differs"): + _mod._adopt_external_lemonade_model("requested-C") + assert env_path.read_text(encoding="utf-8") == original + + +def test_external_adoption_recognizes_existing_verified_route(monkeypatch, tmp_path): + monkeypatch.setattr(_mod, "INSTALL_DIR", tmp_path) + route = tmp_path / "data" / "model-state.json" + _mod._switchboard_state.record_verified_route( + route, catalog_id="loaded-B", runtime_model_id="loaded-B", + backend_kind="lemonade", endpoint_id="lemonade-default", + native_route="loaded-B", context_length=65536, + capabilities={"chat": True}, proof_identity="loaded-B", + ) + before = json.loads(route.read_text(encoding="utf-8")) + assert _mod._external_adoption_route_published("loaded-B", 65536) + assert not _mod._external_adoption_route_published("other-C", 65536) + assert json.loads(route.read_text(encoding="utf-8")) == before + + +def test_external_adoption_preserves_catalog_pixel_viability_advisory(monkeypatch): + monkeypatch.setattr(_mod, "_load_model_library_records", lambda: [{ + "id": "qwen3.5-2b-q4", "gguf_file": "Qwen3.5-2B-Q4_K_M.gguf", + "llm_model_name": "qwen3.5-2b", "app_compatibility": { + "agent_viability": {"status": "not_agent_viable"}, + }, + }]) + small = _mod._external_adoption_capabilities("Qwen3.5-2B-Q4_K_M", 65536) + assert small == {"chat": True, "tools": False, "vision": False, "agentViable": False} + unknown = _mod._external_adoption_capabilities("different-64k-model", 65536) + assert unknown["agentViable"] is True + + def test_host_agent_keeps_gets_alive_and_closes_posts(monkeypatch): class _CountingServer(_mod.ThreadedHTTPServer): accepted_connections = 0 diff --git a/ods/extensions/services/dashboard-api/tests/test_model_transaction.py b/ods/extensions/services/dashboard-api/tests/test_model_transaction.py index 852c08769f..561789e062 100644 --- a/ods/extensions/services/dashboard-api/tests/test_model_transaction.py +++ b/ods/extensions/services/dashboard-api/tests/test_model_transaction.py @@ -100,6 +100,66 @@ def test_partial_host_mutation_cannot_be_recovered_by_a_generic_reset(controller assert calls.count('model-begin')==1 +def test_unreceived_begin_cannot_clear_hold_when_external_model_changed(controller,monkeypatch): + _,state,calls,_=controller + env={'PIXEL_OPENWEBUI_KEY':'configured'} + tx=host._PixelModelTransaction(env) + tx.previous=copy.deepcopy(OLD) + tx._save('prepared') + monkeypatch.setattr(host,'_prove_pixel_model_contract',lambda *_:False) + assert host._recover_pixel_model_transaction(env)['pending'] is True + assert host._pixel_model_recovery_status()['pending'] is True + assert state['transactionId'] is None + assert 'model-finish' not in calls + monkeypatch.setattr(host,'_prove_pixel_model_contract',lambda *_:True) + assert host._recover_pixel_model_transaction(env)['outcome']=='rollback' + + +def test_external_adoption_reuses_only_same_confirmed_held_transaction(controller): + _,state,calls,_=controller + env={'PIXEL_OPENWEBUI_KEY':'configured'} + target={'model':'loaded-B','contextLength':65536,'maxTokens':8192,'reasoning':False} + transaction=host._begin_or_resume_external_pixel_transaction(env,target) + journal=host._read_pixel_model_journal() + assert journal['phase']=='held' and journal['target']==target + resumed=host._begin_or_resume_external_pixel_transaction(env,target) + assert resumed.id==transaction.id==state['transactionId'] + assert calls.count('model-begin')==1 + with pytest.raises(host._PixelModelTransactionUncertain): + host._begin_or_resume_external_pixel_transaction(env,{**target,'model':'other-C'}) + assert calls.count('model-begin')==1 + + +@pytest.mark.parametrize('journal_phase', ['applying', 'applied']) +def test_external_adoption_resumes_proved_apply_without_replaying_it(controller, journal_phase): + _,state,calls,_=controller + env={'PIXEL_OPENWEBUI_KEY':'configured'} + target={'model':'loaded-B','contextLength':65536,'maxTokens':8192,'reasoning':False} + transaction=host._begin_or_resume_external_pixel_transaction(env,target) + transaction.apply(target) + if journal_phase=='applying': + transaction._save('applying') + resumed=host._begin_or_resume_external_pixel_transaction(env,target) + assert resumed.id==transaction.id==state['transactionId'] + assert resumed.journal['phase']=='applied' + assert calls.count('model-begin')==1 + assert calls.count('model-apply')==1 + resumed.finish('commit') + assert calls.count('model-finish')==1 + + +def test_external_adoption_does_not_replay_unproved_apply(controller): + _,_,calls,_=controller + env={'PIXEL_OPENWEBUI_KEY':'configured'} + target={'model':'loaded-B','contextLength':65536,'maxTokens':8192,'reasoning':False} + transaction=host._begin_or_resume_external_pixel_transaction(env,target) + transaction._save('applying') + with pytest.raises(host._PixelModelTransactionUncertain): + host._begin_or_resume_external_pixel_transaction(env,target) + assert calls.count('model-apply')==0 + assert calls.count('model-finish')==0 + + @pytest.mark.parametrize('outcome',['commit','rollback']) def test_finish_recovery_qualifies_exact_state_when_one_gate_was_already_released(controller,monkeypatch,outcome): config,state,calls,call=controller diff --git a/ods/extensions/services/dashboard-api/tests/test_models.py b/ods/extensions/services/dashboard-api/tests/test_models.py index 6f73df57f2..a57b49f30a 100644 --- a/ods/extensions/services/dashboard-api/tests/test_models.py +++ b/ods/extensions/services/dashboard-api/tests/test_models.py @@ -1716,6 +1716,108 @@ def test_api_models_returns_full_catalog_without_fake_tokens(test_client, monkey assert payload["models"][0]["tokensPerSec"] is None assert payload["models"][0]["tokensPerSecEstimate"] == 130 assert payload["models"][0]["performance"]["source"] == "benchmark_required" + assert payload["externalLemonade"] is False + + +def test_api_models_reports_unmatched_external_runtime_without_fake_performance(test_client, monkeypatch, tmp_path): + models_router, install_dir, _data_dir = _patch_model_router_paths(monkeypatch, tmp_path) + monkeypatch.setattr(models_router, "LLM_BACKEND", "lemonade") + monkeypatch.setattr(models_router, "read_live_env_values", lambda _keys: { + "LLM_BACKEND": "lemonade", + "AMD_INFERENCE_RUNTIME_MODE": "external-lemonade", + "AMD_INFERENCE_MANAGED": "false", + }) + _write_model_library(install_dir, [{ + "id": "qwen3.6-35b-a3b-ud-q4", + "name": "Qwen 3.6 35B-A3B", + "gguf_file": "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf", + "size_mb": 21110, + "vram_required_gb": 24, + "context_length": 131072, + "quantization": "UD-Q4_K_M", + "specialty": "Quality", + "description": "Catalog quantization, not the observed external runtime.", + "llm_model_name": "qwen3.6-35b-a3b", + }]) + runtime_name = "Qwen3.6-35B-A3B-GGUF" + recorded = [] + monkeypatch.setattr(models_router, "get_gpu_info", lambda: _gpu()) + monkeypatch.setattr(models_router, "get_loaded_model", AsyncMock(return_value=runtime_name)) + monkeypatch.setattr(models_router, "get_llama_metrics", AsyncMock(return_value={"tokens_per_second": 42})) + monkeypatch.setattr(models_router, "get_llama_context_size", AsyncMock(return_value=None)) + monkeypatch.setattr(models_router, "record_model_performance", lambda *args, **kwargs: recorded.append((args, kwargs))) + + response = test_client.get("/api/models", headers=test_client.auth_headers) + + assert response.status_code == 200 + payload = response.json() + active = [entry for entry in payload["models"] if entry["status"] == "loaded"] + assert len(active) == 1 + assert active[0]["name"] == runtime_name + assert active[0]["metadata"]["source"] == "runtime" + assert active[0]["sizeGb"] is None + assert active[0]["vramRequired"] is None + assert active[0]["quantization"] is None + assert payload["currentModel"] is None + assert payload["activationReadyModel"] is None + assert payload["loadedModel"] == runtime_name + assert payload["externalLemonade"] is True + assert recorded == [] + + +@pytest.mark.parametrize( + ("backend", "runtime_mode", "managed", "external", "expected"), + [ + ("lemonade", "external-lemonade", "false", "", True), + ("lemonade", "windows-legacy-lemonade", "true", "", False), + ("lemonade", "", "", "true", True), + ("lemonade", "", "false", "", True), + ("llama-server", "external-lemonade", "false", "true", True), + ("llama-server", "linux-container", "true", "", False), + ], +) +def test_external_lemonade_runtime_flag( + monkeypatch, backend, runtime_mode, managed, external, expected +): + import routers.models as models_router + + monkeypatch.setattr(models_router, "read_live_env_values", lambda _keys: { + "LLM_BACKEND": backend, + "AMD_INFERENCE_RUNTIME_MODE": runtime_mode, + "AMD_INFERENCE_MANAGED": managed, + "LEMONADE_EXTERNAL": external, + }) + + assert models_router._external_lemonade_runtime() is expected + + +def test_load_model_rejects_external_lemonade_before_catalog_lookup(test_client, monkeypatch, tmp_path): + models_router, install_dir, _data_dir = _patch_model_router_paths(monkeypatch, tmp_path) + (install_dir / ".env").write_text("ODS_MODE=lemonade\n", encoding="utf-8") + monkeypatch.setattr(models_router, "ODS_MODE_EFFECTIVE", "lemonade") + monkeypatch.setattr(models_router, "LLM_BACKEND", "lemonade") + monkeypatch.setattr(models_router, "read_live_env_values", lambda _keys: { + "LLM_BACKEND": "lemonade", + "LEMONADE_EXTERNAL": "true", + }) + monkeypatch.setattr( + models_router, + "_find_loadable_model", + lambda _model_id: (_ for _ in ()).throw( + AssertionError("external Lemonade activation reached catalog lookup") + ), + ) + + response = test_client.post( + "/api/models/downloaded-model/load", headers=test_client.auth_headers + ) + + assert response.status_code == 409 + assert response.json()["detail"] == { + "error": "Externally managed Lemonade cannot use local model activation", + "code": "external_runtime_unmanaged", + "requestedModelId": "downloaded-model", + } def test_download_model_rejects_while_bootstrap_upgrade_active(test_client, monkeypatch, tmp_path): @@ -1957,6 +2059,26 @@ def test_api_models_falls_back_to_loaded_model_probe(test_client, monkeypatch, t assert payload["models"][0]["performance"]["source"] == "measured_local" +def test_lemonade_model_probe_uses_physical_backend_not_litellm_alias(monkeypatch, tmp_path): + import routers.models as models_router + + values = { + "LLM_API_URL": "http://litellm:4000", + "LEMONADE_CONTAINER_BASE_URL": "http://192.168.0.166:8080", + "LEMONADE_BASE_URL": "http://192.168.0.167:8080", + } + monkeypatch.setattr(models_router, "INSTALL_DIR", str(tmp_path)) + monkeypatch.setattr(models_router, "read_env_value", lambda key, _root: values.get(key)) + monkeypatch.setattr(models_router, "LLM_BACKEND", "lemonade") + + assert models_router._configured_llm_base_url("llama-server", 8080) == "http://192.168.0.166:8080" + values.pop("LEMONADE_CONTAINER_BASE_URL") + assert models_router._configured_llm_base_url("llama-server", 8080) == "http://192.168.0.167:8080" + + monkeypatch.setattr(models_router, "LLM_BACKEND", "llama-server") + assert models_router._configured_llm_base_url("llama-server", 8080) == "http://litellm:4000" + + def test_api_models_marks_installer_configured_model(test_client, monkeypatch, tmp_path): models_router, install_dir, _data_dir = _patch_model_router_paths(monkeypatch, tmp_path) _write_model_library(install_dir, [{ diff --git a/ods/extensions/services/dashboard-api/tests/test_performance_oracle.py b/ods/extensions/services/dashboard-api/tests/test_performance_oracle.py index cfc6a16658..622886d69d 100644 --- a/ods/extensions/services/dashboard-api/tests/test_performance_oracle.py +++ b/ods/extensions/services/dashboard-api/tests/test_performance_oracle.py @@ -2,7 +2,7 @@ from pathlib import Path from helpers import record_model_performance -from models import GPUInfo +from models import GPUInfo, ModelLibraryResponse from performance_oracle import ( build_models_payload, current_model_matches, @@ -162,6 +162,46 @@ def test_real_catalog_phi_models_have_exactly_one_loaded_identity(data_dir, tmp_ assert payload["currentModel"] == expected_id +def test_unmatched_runtime_model_is_visible_without_borrowing_catalog_metadata(data_dir, tmp_path): + install_dir = tmp_path / "ods" + install_dir.mkdir() + catalog_model = { + **_model(), + "id": "qwen3.6-35b-a3b-ud-q4", + "name": "Qwen 3.6 35B-A3B", + "gguf_file": "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf", + "llm_model_name": "qwen3.6-35b-a3b", + "quantization": "UD-Q4_K_M", + } + runtime_name = "Qwen3.6-35B-A3B-GGUF" + + payload = build_models_payload( + _gpu(), runtime_name, 0, install_dir, data_dir, catalog=[catalog_model], evidence=[] + ) + loaded = [entry for entry in payload["models"] if entry["status"] == "loaded"] + + assert len(loaded) == 1 + assert loaded[0]["id"].startswith("runtime-") + assert loaded[0]["name"] == runtime_name + assert loaded[0]["metadata"]["source"] == "runtime" + assert loaded[0]["metadata"]["readable"] is False + for field in ("gguf", "downloadUrl", "size", "sizeGb", "vramRequired", + "estimatedRequired", "contextLength", "quantization", "architecture", + "fitsVram", "fitsCurrentVram", "activationSupport"): + assert loaded[0].get(field) is None, field + assert payload["currentModel"] is None + assert payload["loadedModel"] == runtime_name + assert payload["models"][0]["status"] != "loaded" + ModelLibraryResponse(**payload) + + matched = build_models_payload( + _gpu(), "qwen3.6-35b-a3b", 0, install_dir, data_dir, + catalog=[catalog_model], evidence=[], + ) + assert [entry["id"] for entry in matched["models"] if entry["status"] == "loaded"] == [catalog_model["id"]] + assert not any(entry["metadata"]["source"] == "runtime" for entry in matched["models"]) + + def test_benchmark_required_without_measurement_or_evidence(data_dir, tmp_path): install_dir = tmp_path / "ods" (install_dir / "data" / "models").mkdir(parents=True) @@ -588,7 +628,7 @@ def test_real_catalog_gemma_perplexica_block_is_global(): assert tower2["perplexica"]["status"] == "unsupported_until_revalidated" -def test_real_catalog_granite32_perplexica_block_includes_tower3_after_live_failure(): +def test_real_catalog_granite32_perplexica_block_includes_tower1_and_tower3_after_live_failure(): by_id = {model["id"]: model for model in _official_model_catalog()} model = by_id["granite3.2-2b-instruct-q4"] @@ -612,13 +652,19 @@ def test_real_catalog_granite32_perplexica_block_includes_tower3_after_live_fail model, runtime_context={"host": "tower3", "hosts": ["tower3"]}, ) + tower1 = model_app_compatibility( + model, + runtime_context={"host": "tower1", "hosts": ["tower1"]}, + ) assert windows_laptop["perplexica"]["status"] == "unknown" assert strix_halo["perplexica"]["status"] == "unknown" assert tower2["perplexica"]["status"] == "unsupported_until_revalidated" assert m5_mbp["perplexica"]["status"] == "unsupported_until_revalidated" assert tower3["perplexica"]["status"] == "unsupported_until_revalidated" + assert tower1["perplexica"]["status"] == "unsupported_until_revalidated" assert "Tower3" in tower3["perplexica"]["reason"] + assert "Tower1" in tower1["perplexica"]["reason"] def test_real_catalog_smollm3_perplexica_block_is_global(): diff --git a/ods/extensions/services/dashboard-api/tests/test_pixel.py b/ods/extensions/services/dashboard-api/tests/test_pixel.py index eae19ea08a..bf3d64f3bf 100644 --- a/ods/extensions/services/dashboard-api/tests/test_pixel.py +++ b/ods/extensions/services/dashboard-api/tests/test_pixel.py @@ -339,6 +339,83 @@ async def test_status_returns_only_fixed_projection(): assert secret not in json.dumps(result) +@pytest.mark.asyncio +async def test_status_projects_live_fixed_external_host_without_private_origin(monkeypatch): + monkeypatch.setenv("LLM_BACKEND", "external") + values = { + "LLM_BACKEND": "external", + "ODS_MODEL_SWITCHBOARD": "observe", + "EXTERNAL_LLM_PROVIDER": "openai-compatible", + "EXTERNAL_LLM_MODEL": "Qwen3.5-9B-Q4_K_M.gguf", + } + monkeypatch.setattr(pixel, "read_live_env_value", lambda key, default="": values.get(key, default)) + + async def loaded(): + return "Qwen3.5-9B-Q4_K_M.gguf" + + async def context(model): + assert model == "Qwen3.5-9B-Q4_K_M.gguf" + return 65536 + + monkeypatch.setattr(pixel, "get_loaded_model", loaded) + monkeypatch.setattr(pixel, "get_llama_context_size", context) + body = json.dumps({"data": [{"id": "pixel/default"}]}).encode() + with patch.object(pixel.httpx, "AsyncClient", return_value=FakeClient(FakeResponse(chunks=[body]))): + result = await pixel.pixel_status() + + assert result["runtime"] == { + "source": "external-host", "model": "Qwen3.5-9B-Q4_K_M.gguf", "contextLength": 65536, + } + assert "host.lima.internal" not in json.dumps(result) + assert "apiKey" not in json.dumps(result) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("loaded_model", [None, "different-model"]) +async def test_status_does_not_invent_external_host_identity_from_env(monkeypatch, loaded_model): + monkeypatch.setenv("LLM_BACKEND", "external") + values = { + "LLM_BACKEND": "external", + "ODS_MODEL_SWITCHBOARD": "observe", + "EXTERNAL_LLM_PROVIDER": "openai-compatible", + "EXTERNAL_LLM_MODEL": "Qwen3.5-9B-Q4_K_M.gguf", + } + monkeypatch.setattr(pixel, "read_live_env_value", lambda key, default="": values.get(key, default)) + + async def loaded(): + return loaded_model + + monkeypatch.setattr(pixel, "get_loaded_model", loaded) + body = json.dumps({"data": [{"id": "pixel/default"}]}).encode() + with patch.object(pixel.httpx, "AsyncClient", return_value=FakeClient(FakeResponse(chunks=[body]))): + result = await pixel.pixel_status() + assert "runtime" not in result + + +@pytest.mark.asyncio +async def test_external_host_identity_omits_unverified_context(monkeypatch): + monkeypatch.setenv("LLM_BACKEND", "external") + values = { + "LLM_BACKEND": "external", + "ODS_MODEL_SWITCHBOARD": "observe", + "EXTERNAL_LLM_PROVIDER": "openai-compatible", + "EXTERNAL_LLM_MODEL": "Qwen3.5-9B-Q4_K_M.gguf", + } + monkeypatch.setattr(pixel, "read_live_env_value", lambda key, default="": values.get(key, default)) + + async def loaded(): + return "Qwen3.5-9B-Q4_K_M.gguf" + + async def unknown_context(_model): + return None + + monkeypatch.setattr(pixel, "get_loaded_model", loaded) + monkeypatch.setattr(pixel, "get_llama_context_size", unknown_context) + assert await pixel._verified_external_host_runtime({"status": "idle"}) == { + "source": "external-host", "model": "Qwen3.5-9B-Q4_K_M.gguf", + } + + @pytest.mark.asyncio async def test_status_projects_only_validated_active_remote_runtime(monkeypatch): async def active_remote_runtime(*_args, **_kwargs): @@ -384,6 +461,9 @@ async def active_remote_runtime(*_args, **_kwargs): {"source": "local-switchboard", "model": "local-model", "contextLength": 0}, {"source": "local-switchboard", "model": "local-model", "contextLength": 65536, "apiKey": "must-not-project"}, + {"source": "external-host", "model": "Qwen.gguf", "apiKey": "must-not-project"}, + {"source": "external-host", "model": "Qwen.gguf", "contextLength": True}, + {"source": "external-host", "model": "http://private-origin"}, { "source": "local", "model": "forged", @@ -432,6 +512,104 @@ async def local_status(*_args, **_kwargs): assert result["modelSupport"]["tier"] == "adaptive" +@pytest.mark.asyncio +async def test_lemonade_model_drift_blocks_pixel_status_and_chat(monkeypatch): + runtime = {"source": "local-switchboard", "model": "Qwen3.6-35B-A3B-GGUF", + "contextLength": 65536} + + async def recorded_status(*_args, **_kwargs): + return {"status": "idle", "activeRuntime": runtime} + + async def physical_model(): + return "Qwen3.5-2B-Q4_K_M" + + monkeypatch.setattr(pixel, "request_agent_json", recorded_status) + monkeypatch.setattr(pixel, "read_live_env_value", + lambda key: "lemonade" if key == "LLM_BACKEND" else "") + monkeypatch.setattr(pixel, "get_loaded_model", physical_model) + with patch.object(pixel.httpx, "AsyncClient", + side_effect=AssertionError("stale route reached Pixel edge")): + status = await pixel.pixel_status() + body = pixel.ChatStreamRequest( + chat_id="drift-test", messages=[{"role": "user", "content": "hello"}] + ) + with pytest.raises(HTTPException) as raised: + await pixel.pixel_chat_stream(ConnectedRequest(), body) + assert status == { + "available": False, "model": None, "state": "model_unavailable", + "detail": pixel._MODEL_IDENTITY_DETAIL, + } + assert raised.value.status_code == 409 + assert raised.value.detail == pixel._MODEL_IDENTITY_DETAIL + + +@pytest.mark.asyncio +@pytest.mark.parametrize("loaded", ["Qwen3.6-35B-A3B-GGUF", "Qwen3.6-35B-A3B-GGUF.gguf"]) +async def test_matching_lemonade_model_keeps_pixel_available(monkeypatch, loaded): + runtime = {"source": "local-switchboard", "model": "Qwen3.6-35B-A3B-GGUF", + "contextLength": 65536} + + async def recorded_status(*_args, **_kwargs): + return {"status": "idle", "activeRuntime": runtime} + + async def physical_model(): + return loaded + + monkeypatch.setattr(pixel, "request_agent_json", recorded_status) + monkeypatch.setattr(pixel, "read_live_env_value", + lambda key: "lemonade" if key == "LLM_BACKEND" else "") + monkeypatch.setattr(pixel, "get_loaded_model", physical_model) + body = json.dumps({"data": [{"id": "pixel/default"}]}).encode() + with patch.object(pixel.httpx, "AsyncClient", + return_value=FakeClient(FakeResponse(chunks=[body]))): + status = await pixel.pixel_status() + assert status["available"] is True + assert status["runtime"] == runtime + + +@pytest.mark.asyncio +async def test_lemonade_probe_failure_fails_closed_without_logging_endpoint(monkeypatch, caplog): + runtime = {"source": "local-switchboard", "model": "Qwen3.6-35B-A3B-GGUF", + "contextLength": 65536} + + async def recorded_status(*_args, **_kwargs): + return {"status": "idle", "activeRuntime": runtime} + + async def failed_probe(): + raise RuntimeError("private Lemonade origin and token") + + monkeypatch.setattr(pixel, "request_agent_json", recorded_status) + monkeypatch.setattr(pixel, "read_live_env_value", + lambda key: "lemonade" if key == "LLM_BACKEND" else "") + monkeypatch.setattr(pixel, "get_loaded_model", failed_probe) + result = await pixel.pixel_status() + assert result["available"] is False + assert result["state"] == "model_unavailable" + assert "private Lemonade" not in caplog.text + + +@pytest.mark.asyncio +async def test_non_lemonade_runtime_does_not_probe_lemonade_identity(monkeypatch): + runtime = {"source": "local-switchboard", "model": "local-model", + "contextLength": 65536} + + async def recorded_status(*_args, **_kwargs): + return {"status": "idle", "activeRuntime": runtime} + + async def forbidden_probe(): + raise AssertionError("non-Lemonade route was probed") + + monkeypatch.setattr(pixel, "request_agent_json", recorded_status) + monkeypatch.setattr(pixel, "read_live_env_value", lambda _key: "llama.cpp") + monkeypatch.setattr(pixel, "get_loaded_model", forbidden_probe) + body = json.dumps({"data": [{"id": "pixel/default"}]}).encode() + with patch.object(pixel.httpx, "AsyncClient", + return_value=FakeClient(FakeResponse(chunks=[body]))): + status = await pixel.pixel_status() + assert status["available"] is True + assert status["runtime"] == runtime + + def test_active_runtime_projection_accepts_a_constrained_adaptive_context(): runtime = { "source": "remote-provider", @@ -479,6 +657,28 @@ async def active_model_lifecycle(*_args, **_kwargs): assert "secret-model-name" not in json.dumps(result) +@pytest.mark.asyncio +async def test_pending_native_model_transaction_blocks_pixel_after_host_restart(monkeypatch): + async def pending_transaction(*_args, **_kwargs): + return {"status": "idle", "modelTransactionPending": True} + + monkeypatch.setattr(pixel, "request_agent_json", pending_transaction) + with patch.object( + pixel.httpx, + "AsyncClient", + side_effect=AssertionError("pending model transaction reached Pixel edge"), + ): + status = await pixel.pixel_status() + assert status["available"] is False + assert status["state"] == "model_switching" + body = pixel.ChatStreamRequest.model_validate({ + "chat_id": "pending_model", "messages": [{"role": "user", "content": "hello"}], + }) + with pytest.raises(HTTPException) as exc: + await pixel.pixel_chat_stream(ConnectedRequest(), body) + assert exc.value.status_code == 409 + + @pytest.mark.asyncio async def test_status_keeps_adaptive_model_available_with_fixed_advisory(monkeypatch): async def adaptive_model(*_args, **_kwargs): @@ -565,6 +765,86 @@ async def test_chat_forwards_exact_body_and_narrow_edge_key_only(): assert pixel_runtime_state._local_pixel_stream_active() is False +@pytest.mark.asyncio +async def test_chat_keeps_silent_local_inference_stream_alive_without_faking_answer(monkeypatch): + first = b'data: {"choices":[{"delta":{"content":"first"}}]}\n\n' + done = b"data: [DONE]\n\n" + + class SlowResponse(FakeResponse): + async def aiter_bytes(self): + yield first + await asyncio.sleep(0.08) + yield done + + monkeypatch.setattr(pixel, "_STREAM_KEEPALIVE_SECONDS", 0.02) + monkeypatch.setattr(pixel, "_CLIENT_DISCONNECT_POLL_SECONDS", 0.005) + body = pixel.ChatStreamRequest.model_validate( + {"chat_id": "slow_cpu", "messages": [{"role": "user", "content": "hello"}]} + ) + upstream = SlowResponse(content_type="text/event-stream") + with patch.object(pixel.httpx, "AsyncClient", return_value=FakeClient(upstream)): + response = await pixel.pixel_chat_stream(ConnectedRequest(), body) + streamed = await stream_body(response) + + assert streamed.startswith(first) + assert streamed.endswith(done) + assert pixel._STREAM_KEEPALIVE in streamed[len(first):-len(done)] + assert streamed.count(b"data: [DONE]") == 1 + assert pixel_runtime_state._local_pixel_stream_active() is False + + +@pytest.mark.asyncio +async def test_chat_keepalive_before_first_upstream_byte_is_only_a_comment(monkeypatch): + class SlowFirstResponse(FakeResponse): + async def aiter_bytes(self): + await asyncio.sleep(0.08) + yield b'data: {"choices":[{"delta":{"content":"ready"}}]}\n\n' + yield b'data: [DONE]\n\n' + + monkeypatch.setattr(pixel, "_STREAM_KEEPALIVE_SECONDS", 0.02) + monkeypatch.setattr(pixel, "_CLIENT_DISCONNECT_POLL_SECONDS", 0.005) + body = pixel.ChatStreamRequest.model_validate( + {"chat_id": "slow_first_byte", "messages": [{"role": "user", "content": "hello"}]} + ) + upstream = SlowFirstResponse(content_type="text/event-stream") + with patch.object(pixel.httpx, "AsyncClient", return_value=FakeClient(upstream)): + response = await pixel.pixel_chat_stream(ConnectedRequest(), body) + streamed = await stream_body(response) + + assert streamed.startswith(pixel._STREAM_KEEPALIVE) + assert b'ready' in streamed + assert streamed.endswith(b'data: [DONE]\n\n') + + +@pytest.mark.asyncio +async def test_chat_keepalive_never_splits_an_upstream_sse_line(monkeypatch): + first = b'data: {"choices":[{"delta":{"content":"par' + last = b'tial"}}]}\n\n' + done = b'data: [DONE]\n\n' + + class FragmentedResponse(FakeResponse): + async def aiter_bytes(self): + yield first + await asyncio.sleep(0.08) + yield last + await asyncio.sleep(0.08) + yield done + + monkeypatch.setattr(pixel, "_STREAM_KEEPALIVE_SECONDS", 0.02) + monkeypatch.setattr(pixel, "_CLIENT_DISCONNECT_POLL_SECONDS", 0.005) + body = pixel.ChatStreamRequest.model_validate( + {"chat_id": "fragmented_cpu", "messages": [{"role": "user", "content": "hello"}]} + ) + upstream = FragmentedResponse(content_type="text/event-stream") + with patch.object(pixel.httpx, "AsyncClient", return_value=FakeClient(upstream)): + response = await pixel.pixel_chat_stream(ConnectedRequest(), body) + streamed = await stream_body(response) + + assert streamed.startswith(first + last) + assert pixel._STREAM_KEEPALIVE in streamed[len(first + last):-len(done)] + assert streamed.endswith(done) + + @pytest.mark.asyncio async def test_chat_rejects_before_opening_edge_when_stream_capacity_is_full(monkeypatch): body = pixel.ChatStreamRequest.model_validate( diff --git a/ods/extensions/services/dashboard-api/tests/test_pixel_result_delivery.py b/ods/extensions/services/dashboard-api/tests/test_pixel_result_delivery.py index 8615bd40ed..8c64cbc28c 100644 --- a/ods/extensions/services/dashboard-api/tests/test_pixel_result_delivery.py +++ b/ods/extensions/services/dashboard-api/tests/test_pixel_result_delivery.py @@ -53,3 +53,37 @@ async def cancel(*args): assert await stream_body(replay) == retained asyncio.run(run()) + + +def test_retained_subscriber_keepalive_is_not_part_of_durable_reply(store, monkeypatch): + async def run(): + first = b'data: {"choices":[{"delta":{"content":"First "}}]}\n\n' + last = b'data: {"choices":[{"delta":{"content":"last"}}]}\n\n' + + class SlowUpstream(FakeResponse): + async def aiter_bytes(self): + yield first + await asyncio.sleep(0.08) + yield last + yield b'data: [DONE]\n\n' + + monkeypatch.setattr(pixel, '_STREAM_KEEPALIVE_SECONDS', 0.02) + monkeypatch.setattr(pixel, '_CLIENT_DISCONNECT_POLL_SECONDS', 0.005) + monkeypatch.setattr( + pixel.httpx, 'AsyncClient', + lambda **kw: FakeClient(SlowUpstream(content_type='text/event-stream')), + ) + response = await pixel.pixel_chat_stream(ConnectedRequest(), body(), OWNER) + streamed = await stream_body(response) + retained = b''.join(row['data'] for row in store.chunks(IDENTITY)) + + assert first in streamed and last in streamed + assert pixel._STREAM_KEEPALIVE in streamed + assert pixel._STREAM_KEEPALIVE not in retained + assert b'First ' in retained and b'last' in retained + assert retained.count(b'data: [DONE]') == 1 + assert store.get(IDENTITY)['state'] == 'complete' + replay = await pixel.pixel_chat_stream(ConnectedRequest(), body(), OWNER) + assert await stream_body(replay) == retained + + asyncio.run(run()) diff --git a/ods/extensions/services/dashboard/src/components/ExternalLemonadeAdoption.jsx b/ods/extensions/services/dashboard/src/components/ExternalLemonadeAdoption.jsx new file mode 100644 index 0000000000..2de8f8979b --- /dev/null +++ b/ods/extensions/services/dashboard/src/components/ExternalLemonadeAdoption.jsx @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useState } from 'react' +import { AlertCircle, RefreshCw } from 'lucide-react' +import { Link } from 'react-router-dom' + +function errorText(payload, fallback) { + const detail = payload?.detail ?? payload + if (typeof detail?.error === 'string') return detail.error + if (typeof detail?.message === 'string') return detail.message + if (typeof detail === 'string') return detail + return fallback +} + +export default function ExternalLemonadeAdoption({ enabled, minimumContext, onSettled, compact = false }) { + const [observation, setObservation] = useState(null) + const [checking, setChecking] = useState(false) + const [adopting, setAdopting] = useState(false) + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + const [available, setAvailable] = useState(false) + + const inspect = useCallback(async (signal) => { + const response = await fetch('/api/models/external-observation', { signal, cache: 'no-store' }) + if (response.status === 409) return null + const payload = await response.json() + if (!response.ok || payload?.status !== 'verified' || typeof payload.modelId !== 'string') { + throw new Error(errorText(payload, 'Could not verify the model loaded in Lemonade.')) + } + return payload + }, []) + + useEffect(() => { + if (!enabled) return undefined + const controller = new AbortController() + setChecking(true) + void inspect(controller.signal).then(value => { + setObservation(value) + setAvailable(Boolean(value)) + setError(null) + }).catch(cause => { + if (!controller.signal.aborted) setError(cause.message) + }).finally(() => { + if (!controller.signal.aborted) setChecking(false) + }) + return () => controller.abort() + }, [enabled, inspect]) + + const recheck = async () => { + setChecking(true) + setError(null) + try { + const value = await inspect() + setObservation(value) + setAvailable(Boolean(value)) + } catch (cause) { + setError(cause.message) + } finally { + setChecking(false) + } + } + + const adopt = async () => { + if (!observation || adopting) return + setAdopting(true) + setError(null) + try { + // The user must see and select the exact physical model. If it changed + // since the card rendered, require another click after showing the new one. + const fresh = await inspect() + if (!fresh || fresh.modelId !== observation.modelId || + fresh.contextLength !== observation.contextLength) { + setObservation(fresh) + setError('The loaded Lemonade model changed. Review it and retry adoption.') + return + } + const response = await fetch('/api/models/external-adopt', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model_id: fresh.modelId }), + }) + const payload = await response.json() + if (!response.ok || payload?.status !== 'adopted' || payload.modelId !== fresh.modelId) { + setPending(payload?.detail?.pending === true) + throw new Error(errorText(payload, 'ODS could not confirm model adoption.')) + } + setPending(false) + try { + await onSettled?.() + } catch { + setError('The model was adopted, but the dashboard could not refresh. Reload to check its status.') + } + } catch (cause) { + setError(cause.message) + } finally { + setAdopting(false) + } + } + + if (!enabled || (!available && !checking && !error)) return null + const context = Number(observation?.contextLength || 0) + const tooSmall = context > 0 && context < minimumContext + return ( +
+
+
+
+
+ + +
+
+
+ ) +} diff --git a/ods/extensions/services/dashboard/src/components/ExternalLemonadeAdoption.test.jsx b/ods/extensions/services/dashboard/src/components/ExternalLemonadeAdoption.test.jsx new file mode 100644 index 0000000000..906f2d131a --- /dev/null +++ b/ods/extensions/services/dashboard/src/components/ExternalLemonadeAdoption.test.jsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import ExternalLemonadeAdoption from './ExternalLemonadeAdoption' + +const loaded = { status: 'verified', modelId: 'Qwen3.5-2B-Q4_K_M', contextLength: 65536, backend: 'vulkan' } +const response = (body, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body }) +const view = (props = {}) => render( + +) +const clickAdopt = async () => { + const button = await screen.findByRole('button', { name: 'Adopt loaded model in ODS' }) + await waitFor(() => expect(button).toBeEnabled()) + fireEvent.click(button) +} + +afterEach(() => vi.unstubAllGlobals()) + +test('keeps nonexternal Lemonade installations free of an adoption control', async () => { + const fetch = vi.fn().mockResolvedValue(response({}, 409)) + vi.stubGlobal('fetch', fetch) + view() + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)) + expect(screen.queryByRole('region', { name: 'External Lemonade model' })).toBeNull() +}) + +test('rechecks the exact physical model before one adoption and refreshes ODS', async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(response(loaded)) + .mockResolvedValueOnce(response(loaded)) + .mockResolvedValueOnce(response({ status: 'adopted', modelId: loaded.modelId })) + vi.stubGlobal('fetch', fetch) + const onSettled = vi.fn() + view({ onSettled }) + await clickAdopt() + await waitFor(() => expect(onSettled).toHaveBeenCalledTimes(1)) + expect(fetch).toHaveBeenCalledTimes(3) + expect(fetch.mock.calls[2][0]).toBe('/api/models/external-adopt') + expect(JSON.parse(fetch.mock.calls[2][1].body)).toEqual({ model_id: loaded.modelId }) +}) + +test('keeps the narrow Portal model drawer concise without hiding the native ownership warning', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response(loaded))) + view({ compact: true }) + expect(await screen.findByText(/After switching in Lemonade, adopt here/)).toBeVisible() + expect(screen.getByText(/ODS leaves the native model loaded/)).toBeVisible() + await waitFor(() => expect(screen.getByRole('button', { name: 'Adopt loaded model in ODS' })).toBeEnabled()) +}) + +test('a changed external model must be reviewed before adoption', async () => { + const changed = { ...loaded, modelId: 'different-model' } + const fetch = vi.fn().mockResolvedValueOnce(response(loaded)).mockResolvedValueOnce(response(changed)) + vi.stubGlobal('fetch', fetch) + view() + await clickAdopt() + expect(await screen.findByRole('alert')).toHaveTextContent('changed') + expect(screen.getByText('different-model')).toBeVisible() + expect(fetch).toHaveBeenCalledTimes(2) +}) + +test('a refresh error does not misreport a committed adoption as failed', async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(response(loaded)) + .mockResolvedValueOnce(response(loaded)) + .mockResolvedValueOnce(response({ status: 'adopted', modelId: loaded.modelId })) + vi.stubGlobal('fetch', fetch) + view({ onSettled: vi.fn().mockRejectedValue(new Error('refresh unavailable')) }) + await clickAdopt() + expect(await screen.findByRole('alert')).toHaveTextContent('model was adopted') + expect(fetch).toHaveBeenCalledTimes(3) +}) + +test('pending adoption tells the user Pixel remains held', async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(response(loaded)) + .mockResolvedValueOnce(response(loaded)) + .mockResolvedValueOnce(response({ detail: { + error: 'Adoption is incomplete', code: 'managed_model_recovery_required', pending: true, + } }, 503)) + vi.stubGlobal('fetch', fetch) + view() + await clickAdopt() + expect(await screen.findByRole('alert')).toHaveTextContent('Adoption is incomplete') + expect(screen.getByText(/Pixel stays held until recovery/)).toBeVisible() + expect(screen.getByRole('link', { name: 'Open Pixel recovery' })).toHaveAttribute('href', '/pixel') +}) + +test('insufficient context cannot be adopted for managed Pixel', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({ ...loaded, contextLength: 8192 }))) + view() + const button = await screen.findByRole('button', { name: 'Adopt loaded model in ODS' }) + expect(button).toBeDisabled() + expect(screen.getByText(/Pixel needs at least/)).toBeVisible() +}) diff --git a/ods/extensions/services/dashboard/src/components/PortalModelSelector.jsx b/ods/extensions/services/dashboard/src/components/PortalModelSelector.jsx index efb3805b52..58780f1d69 100644 --- a/ods/extensions/services/dashboard/src/components/PortalModelSelector.jsx +++ b/ods/extensions/services/dashboard/src/components/PortalModelSelector.jsx @@ -50,7 +50,7 @@ function LoadedModelSelector({activeModel='',runtimeSource,busy=false,onSwitchin const [open,setOpen]=useState(true),[confirmId,setConfirmId]=useState(null),[pending,setPending]=useState(false),[localError,setLocalError]=useState('') const [recoveryPending,setRecoveryPending]=useState(false),[recoveryBusy,setRecoveryBusy]=useState(false) const root=useRef(null),trigger=useRef(null),list=useRef(null),mounted=useRef(true),submitLock=useRef(false) - const id=useId(),remote=runtimeSource==='remote-provider',local=runtimeSource==='local-switchboard' + const id=useId(),remote=runtimeSource==='remote-provider',external=runtimeSource==='external-host',local=runtimeSource==='local-switchboard' const switching=pending || recoveryBusy || Boolean(activationLoading) || Boolean(modelLifecycle?.active && modelLifecycle.operation==='model_activation') const current=local?models.find(model=>model.id===currentModel):null const selectedId=local && activationReadyModel===currentModel?currentModel:null @@ -77,6 +77,7 @@ function LoadedModelSelector({activeModel='',runtimeSource,busy=false,onSwitchin if(switching || modelLifecycle?.active || actionLoadingModels.length)return 'A model operation is in progress.' if(recoveryPending)return 'Recover the interrupted model switch before loading another model.' if(remote)return 'This conversation uses a remote provider. Choose its model in provider settings.' + if(external)return 'This model is managed on the external host. Switch it there.' if(!local)return 'The conversation’s model source is not confirmed. Review Models before switching.' if(busy)return 'Wait for the active task to finish before switching models.' if(!canActivateModels)return activationModeError || 'Model switching is unavailable for this runtime.' @@ -99,7 +100,7 @@ function LoadedModelSelector({activeModel='',runtimeSource,busy=false,onSwitchin if(mounted.current){setPending(false);onSettled?.()} } } - const reason=switching?'':confirmation?unavailable(confirmation):remote?'This conversation uses a remote provider.':!local?'The conversation’s model source is not confirmed.':busy?'The current task is still running.':!canActivateModels && !loading?activationModeError:'' + const reason=switching?'':confirmation?unavailable(confirmation):remote?'This conversation uses a remote provider.':external?'This model is managed on the external host.':!local?'The conversation’s model source is not confirmed.':busy?'The current task is still running.':!canActivateModels && !loading?activationModeError:'' const showActiveFallback=activeModel && !current return
})}
{loading &&

Loading models…

} {!loading && !installed.length && !showActiveFallback &&

No installed models found.

} {reason &&

{reason}

} {(localError || error) &&

{localError || error}

} -