Skip to content
This repository was archived by the owner on Oct 4, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
00a3239
feat(remote-workers): add distributed rendering support
mickr777 Sep 27, 2026
e201aa0
fix(remote-workers): persist queue results and active items
mickr777 Sep 27, 2026
0a4acd1
fix(remote-workers): transfer video inputs to workers
mickr777 Sep 28, 2026
393f2c2
fix(remote-workers): clean up transferred input media
mickr777 Sep 28, 2026
1b88282
fix(remote-workers): fail queue items on remote OOM
mickr777 Sep 28, 2026
aaa82ad
feat(remote-workers): persist settings in encrypted server vault
mickr777 Sep 29, 2026
0c77a0b
fix(remote-workers): validate directory model layout
mickr777 Sep 29, 2026
6f9b57a
fix(remote-workers): restore remote Canvas result routing
mickr777 Sep 29, 2026
70bb10e
Merge branch 'main' into remote-worker-v7
mickr777 Sep 29, 2026
5e2e627
refactor(remote-workers): move availability decisions to backend
mickr777 Sep 29, 2026
9ea5cc6
chore: restore upstream queue comments
mickr777 Sep 29, 2026
5c70773
refactor(remote-workers): keep frontend health display-only
mickr777 Sep 29, 2026
64e12bf
Merge upstream main into remote-worker-v7
mickr777 Sep 29, 2026
c6e5a32
fix(remote-workers): localize widget UI
mickr777 Sep 30, 2026
76b1f0e
fix(remote-workers): correct UTF-8 separators
mickr777 Sep 30, 2026
15efdd1
Merge branch 'main' into remote-worker-v7
mickr777 Sep 30, 2026
f34c1e2
fix(remote-workers): update power toggle immediately
mickr777 Sep 30, 2026
3d9ca40
fix(remote-workers): allow editing worker names
mickr777 Sep 30, 2026
92cb15e
Merge branch 'main' into remote-worker-v7
mickr777 Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions invokeai/app/api/routers/remote_workers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"""Authenticated per-user remote-worker credential management.

Only a saved/not-saved indicator and email are returned; no passwords or JWTs.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any, Literal

from fastapi import HTTPException, Query
from fastapi.routing import APIRouter
from pydantic import BaseModel, ConfigDict, Field

from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.invocations.remote_worker.credential_vault import (
delete_credentials,
get_saved_credentials,
get_saved_settings,
normalize_url,
save_credentials,
save_settings,
)
from invokeai.app.invocations.remote_worker.diffusers_transfer import (
cancel_directory_install_job,
get_directory_install_job,
start_directory_install,
)
from invokeai.app.invocations.remote_worker.model_transfer import model_layout_signature
from invokeai.app.invocations.remote_worker.remote_client import RemoteConfig, RemoteInvokeClient, RemoteInvokeError

remote_workers_router = APIRouter(prefix="/v1/remote_workers", tags=["remote_workers"])


class RemoteWorkerCredentialRequest(BaseModel):
url: str = Field(min_length=1, max_length=2048)
email: str = Field(min_length=1, max_length=320)
password: str = Field(min_length=1, max_length=4096)
remember_me: bool = True


class RemoteWorkerCredentialStatus(BaseModel):
saved: bool
email: str | None = None


def _status(user_id: str, url: str) -> RemoteWorkerCredentialStatus:
record = get_saved_credentials(user_id, url)
return RemoteWorkerCredentialStatus(
saved=record is not None,
email=str(record["email"]) if record and isinstance(record.get("email"), str) else None,
)


class RemoteWorkerAvailability(BaseModel):
status: Literal["online", "offline", "login_required"]


class RemoteWorkersSettings(BaseModel):
model_config = ConfigDict(populate_by_name=True)

enabled: bool = False
dispatch_mode: Literal["distributed", "remote_only"] = Field(default="distributed", alias="dispatchMode")
worker_urls: str = Field(default="", alias="workerUrls", max_length=32768)
worker_names: dict[str, str] = Field(default_factory=dict, alias="workerNames")
disabled_worker_urls: list[str] = Field(default_factory=list, alias="disabledWorkerUrls")
auto_transfer_missing_models: bool = Field(default=True, alias="autoTransferMissingModels")
keep_remote_copies: bool = Field(default=False, alias="keepRemoteCopies")
model_transfer_host: str = Field(default="", alias="modelTransferHost", max_length=2048)


@remote_workers_router.get("/settings", response_model=RemoteWorkersSettings)
def get_remote_worker_settings(current_user: CurrentUserOrDefault) -> RemoteWorkersSettings:
saved = get_saved_settings(current_user.user_id)
return RemoteWorkersSettings.model_validate(saved) if saved is not None else RemoteWorkersSettings()


@remote_workers_router.put("/settings", response_model=RemoteWorkersSettings)
def put_remote_worker_settings(
current_user: CurrentUserOrDefault,
body: RemoteWorkersSettings,
) -> RemoteWorkersSettings:
save_settings(current_user.user_id, body.model_dump(by_alias=True))
return body


@remote_workers_router.get("/status", response_model=RemoteWorkerAvailability)
def get_remote_worker_status(
current_user: CurrentUserOrDefault,
url: str = Query(min_length=1, max_length=2048),
) -> RemoteWorkerAvailability:
"""Probe this user's ability to reach a configured worker using existing InvokeAI APIs.

Keep the probe short; credentials stay in the primary's per-user vault.
Do not mistake a reachable worker with rejected credentials for an offline host.
"""
try:
normalized = normalize_url(url)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
client = RemoteInvokeClient(
RemoteConfig.from_environment(base_url=normalized, verify_ssl=False, user_id=current_user.user_id),
request_timeout_seconds=2.5,
)
try:
client.get_current_item()
return RemoteWorkerAvailability(status="online")
except RemoteInvokeError as exc:
message = str(exc).lower()
if any(
marker in message
for marker in (
"requires login",
"login failed",
"initial admin setup",
"http 401",
"http 403",
"credentials file",
)
):
return RemoteWorkerAvailability(status="login_required")
return RemoteWorkerAvailability(status="offline")
except Exception:
# An unreachable worker should never make the primary's status API fail.
return RemoteWorkerAvailability(status="offline")


@remote_workers_router.get("/credentials", response_model=RemoteWorkerCredentialStatus)
def get_remote_worker_credentials_status(
current_user: CurrentUserOrDefault,
url: str = Query(min_length=1, max_length=2048),
) -> RemoteWorkerCredentialStatus:
try:
return _status(current_user.user_id, normalize_url(url))
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc


@remote_workers_router.put("/credentials", response_model=RemoteWorkerCredentialStatus)
def put_remote_worker_credentials(
current_user: CurrentUserOrDefault,
body: RemoteWorkerCredentialRequest,
) -> RemoteWorkerCredentialStatus:
try:
save_credentials(current_user.user_id, body.url, body.email, body.password, body.remember_me)
return _status(current_user.user_id, body.url)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc


@remote_workers_router.delete("/credentials", response_model=RemoteWorkerCredentialStatus)
def remove_remote_worker_credentials(
current_user: CurrentUserOrDefault,
url: str = Query(min_length=1, max_length=2048),
) -> RemoteWorkerCredentialStatus:
try:
delete_credentials(current_user.user_id, url)
return RemoteWorkerCredentialStatus(saved=False)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc


class RemoteModelLayout(BaseModel):
kind: Literal["file", "directory"]
signature: str


@remote_workers_router.get("/models/{key}/layout", response_model=RemoteModelLayout)
def get_remote_model_layout(current_user: CurrentUserOrDefault, key: str) -> RemoteModelLayout:
"""Return a non-secret signature of one registered model's file layout."""
services = ApiDependencies.invoker.services
try:
config = services.model_manager.store.get_model(key)
except Exception as exc:
raise HTTPException(status_code=404, detail="Model not found") from exc

model_path = Path(str(getattr(config, "path", "") or ""))
if not model_path.is_absolute():
model_path = Path(services.configuration.models_path) / model_path
model_path = model_path.resolve()
if not model_path.exists():
raise HTTPException(status_code=404, detail="Model files not found")

kind, signature = model_layout_signature(model_path)
return RemoteModelLayout(kind=kind, signature=signature)


@remote_workers_router.post("/diffusers/install")
def install_remote_directory(current_admin: AdminUserOrDefault, body: dict[str, Any]) -> dict[str, Any]:
"""Admin-only receiver for short-lived, manifest-verified model transfers."""
try:
return start_directory_install(body, ApiDependencies.invoker.services)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc


@remote_workers_router.get("/diffusers/install/{job_id}")
def get_remote_directory_install(current_admin: AdminUserOrDefault, job_id: int) -> dict[str, Any]:
"""Poll a directory download and normal model installation."""
job = get_directory_install_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Directory transfer job not found")
return job


@remote_workers_router.delete("/diffusers/install/{job_id}")
def cancel_remote_directory_install(current_admin: AdminUserOrDefault, job_id: int) -> dict[str, Any]:
"""Cancel only this temporary download, preserving completed models."""
job = cancel_directory_install_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Directory transfer job not found")
return job
17 changes: 16 additions & 1 deletion invokeai/app/api/routers/session_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive
from invokeai.app.invocations.fields import ImageField, VideoField
from invokeai.app.invocations.remote_worker.early_dispatch import schedule_automatic_remote_dispatches
from invokeai.app.services.progress_previews.progress_previews_common import ProgressPreviewDTO
from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus
from invokeai.app.services.session_queue.session_queue_common import (
Expand Down Expand Up @@ -256,9 +257,23 @@ async def enqueue_batch(
await asyncio.to_thread(assert_image_move_maintenance_inactive)

try:
return await ApiDependencies.invoker.services.session_queue.enqueue_batch(
result = await ApiDependencies.invoker.services.session_queue.enqueue_batch(
queue_id=queue_id, batch=batch, prepend=prepend, user_id=current_user.user_id
)
# The Remote Worker hook is a no-op for normal batches. It only
# schedules the CPU/network fast lane; remote work still runs off-thread.
try:
schedule_automatic_remote_dispatches(
batch=batch,
item_ids=result.item_ids,
services=ApiDependencies.invoker.services,
)
except Exception as exc:
# No enqueue failure: the normal queued invocation remains the fallback.
ApiDependencies.invoker.services.logger.warning(
f"IRW early remote dispatch unavailable; using normal queue: {exc}"
)
return result
except EnqueueIdempotencyConflictError as e:
raise HTTPException(status_code=409, detail=str(e))
except EnqueueProjectNotFoundError as e:
Expand Down
2 changes: 2 additions & 0 deletions invokeai/app/api_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
model_relationships,
projects,
recall_parameters,
remote_workers,
session_queue,
style_presets,
system_prompts,
Expand Down Expand Up @@ -753,6 +754,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
app.include_router(model_relationships.model_relationships_router, prefix="/api")
app.include_router(app_info.app_router, prefix="/api")
app.include_router(session_queue.session_queue_router, prefix="/api")
app.include_router(remote_workers.remote_workers_router, prefix="/api")
app.include_router(workflows.workflows_router, prefix="/api")
app.include_router(style_presets.style_presets_router, prefix="/api")
app.include_router(wildcards.wildcards_router, prefix="/api")
Expand Down
5 changes: 5 additions & 0 deletions invokeai/app/invocations/remote_worker/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Built-in InvokeAI Remote Worker support.

The invocation is discovered by InvokeAI core module discovery; installing a
standalone custom-node pack is not required.
"""
Loading
Loading