Skip to content
Merged
57 changes: 44 additions & 13 deletions invokeai/app/api/routers/app_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from fastapi.routing import APIRouter
from pydantic import BaseModel, Field, model_validator

from invokeai.app.api.auth_dependencies import AdminUserOrDefault
from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.config.config_default import (
EXTERNAL_PROVIDER_CONFIG_FIELDS,
Expand Down Expand Up @@ -55,7 +55,7 @@ async def get_version() -> AppVersion:


@app_router.get("/app_deps", operation_id="get_app_deps", status_code=200, response_model=dict[str, str])
async def get_app_deps() -> dict[str, str]:
async def get_app_deps(current_user: CurrentUserOrDefault) -> dict[str, str]:
deps: dict[str, str] = {dist.metadata["Name"]: dist.version for dist in distributions()}
try:
cuda = getattr(getattr(torch, "version", None), "cuda", None) or "N/A" # pyright: ignore[reportAttributeAccessIssue]
Expand All @@ -70,7 +70,7 @@ async def get_app_deps() -> dict[str, str]:


@app_router.get("/patchmatch_status", operation_id="get_patchmatch_status", status_code=200, response_model=bool)
async def get_patchmatch_status() -> bool:
async def get_patchmatch_status(current_user: CurrentUserOrDefault) -> bool:
return PatchMatch.patchmatch_available()


Expand Down Expand Up @@ -139,12 +139,42 @@ def validate_explicit_nulls(self) -> "UpdateAppGenerationSettingsRequest":
return self


REDACTED_SECRET = "**********"


def _redact_config_secrets(config: InvokeAIAppConfig) -> InvokeAIAppConfig:
"""Return a copy of the config with credential fields masked.

The runtime config carries provider API keys and model-download bearer tokens. The route is admin-only, but no
client - not even an admin's browser - has any use for the raw values; the UI only cares whether a credential is
configured.

NOTE: coverage is by convention, not automatic. Only `*_api_key` fields listed in
EXTERNAL_PROVIDER_CONFIG_FIELDS plus `remote_api_tokens` are masked. If you add a credential to
InvokeAIAppConfig under any other name, you must extend this function or it will be served verbatim.
"""
updates: dict[str, Any] = {}

for field_name in EXTERNAL_PROVIDER_CONFIG_FIELDS:
if not field_name.endswith("_api_key"):
continue
if getattr(config, field_name, None):
updates[field_name] = REDACTED_SECRET

if config.remote_api_tokens:
updates["remote_api_tokens"] = [
pair.model_copy(update={"token": REDACTED_SECRET}) for pair in config.remote_api_tokens
]

return config.model_copy(update=updates) if updates else config


@app_router.get(
"/runtime_config", operation_id="get_runtime_config", status_code=200, response_model=InvokeAIAppConfigWithSetFields
)
async def get_runtime_config() -> InvokeAIAppConfigWithSetFields:
async def get_runtime_config(current_admin: AdminUserOrDefault) -> InvokeAIAppConfigWithSetFields:
config = get_config()
return InvokeAIAppConfigWithSetFields(set_fields=config.model_fields_set, config=config)
return InvokeAIAppConfigWithSetFields(set_fields=config.model_fields_set, config=_redact_config_secrets(config))


@app_router.patch(
Expand All @@ -169,7 +199,7 @@ async def update_runtime_config(

persisted_config.update_config(update_dict)
persisted_config.write_file(config.config_file_path)
return InvokeAIAppConfigWithSetFields(set_fields=config.model_fields_set, config=config)
return InvokeAIAppConfigWithSetFields(set_fields=config.model_fields_set, config=_redact_config_secrets(config))


@app_router.get(
Expand All @@ -178,7 +208,7 @@ async def update_runtime_config(
status_code=200,
response_model=list[ExternalProviderStatusModel],
)
async def get_external_provider_statuses() -> list[ExternalProviderStatusModel]:
async def get_external_provider_statuses(current_user: CurrentUserOrDefault) -> list[ExternalProviderStatusModel]:
statuses = ApiDependencies.invoker.services.external_generation.get_provider_statuses()
return [status_to_model(status) for status in statuses.values()]

Expand All @@ -189,7 +219,7 @@ async def get_external_provider_statuses() -> list[ExternalProviderStatusModel]:
status_code=200,
response_model=list[ExternalProviderConfigModel],
)
async def get_external_provider_configs() -> list[ExternalProviderConfigModel]:
async def get_external_provider_configs(current_admin: AdminUserOrDefault) -> list[ExternalProviderConfigModel]:
config = get_config()
return [_build_external_provider_config(provider_id, config) for provider_id in EXTERNAL_PROVIDER_FIELDS]

Expand Down Expand Up @@ -340,7 +370,7 @@ def _remove_external_models_for_provider(provider_id: str) -> None:
responses={200: {"description": "The operation was successful"}},
response_model=LogLevel,
)
async def get_log_level() -> LogLevel:
async def get_log_level(current_admin: AdminUserOrDefault) -> LogLevel:
"""Returns the log level"""
return LogLevel(ApiDependencies.invoker.services.logger.level)

Expand All @@ -352,6 +382,7 @@ async def get_log_level() -> LogLevel:
response_model=LogLevel,
)
async def set_log_level(
current_admin: AdminUserOrDefault,
level: LogLevel = Body(description="New log verbosity level"),
) -> LogLevel:
"""Sets the log verbosity level"""
Expand All @@ -364,7 +395,7 @@ async def set_log_level(
operation_id="clear_invocation_cache",
responses={200: {"description": "The operation was successful"}},
)
async def clear_invocation_cache() -> None:
async def clear_invocation_cache(current_admin: AdminUserOrDefault) -> None:
"""Clears the invocation cache"""
ApiDependencies.invoker.services.invocation_cache.clear()

Expand All @@ -374,7 +405,7 @@ async def clear_invocation_cache() -> None:
operation_id="enable_invocation_cache",
responses={200: {"description": "The operation was successful"}},
)
async def enable_invocation_cache() -> None:
async def enable_invocation_cache(current_admin: AdminUserOrDefault) -> None:
"""Clears the invocation cache"""
ApiDependencies.invoker.services.invocation_cache.enable()

Expand All @@ -384,7 +415,7 @@ async def enable_invocation_cache() -> None:
operation_id="disable_invocation_cache",
responses={200: {"description": "The operation was successful"}},
)
async def disable_invocation_cache() -> None:
async def disable_invocation_cache(current_admin: AdminUserOrDefault) -> None:
"""Clears the invocation cache"""
ApiDependencies.invoker.services.invocation_cache.disable()

Expand All @@ -394,6 +425,6 @@ async def disable_invocation_cache() -> None:
operation_id="get_invocation_cache_status",
responses={200: {"model": InvocationCacheStatus}},
)
async def get_invocation_cache_status() -> InvocationCacheStatus:
async def get_invocation_cache_status(current_admin: AdminUserOrDefault) -> InvocationCacheStatus:
"""Clears the invocation cache"""
return ApiDependencies.invoker.services.invocation_cache.get_status()
1 change: 1 addition & 0 deletions invokeai/app/api/routers/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ class ImageUploadEntry(BaseModel):

@images_router.post("/", operation_id="create_image_upload_entry")
async def create_image_upload_entry(
_: CurrentUserOrDefault,
width: int = Body(description="The width of the image"),
height: int = Body(description="The height of the image"),
board_id: Optional[str] = Body(default=None, description="The board to add this image to, if any"),
Expand Down
47 changes: 33 additions & 14 deletions invokeai/app/api/routers/model_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from starlette.exceptions import HTTPException
from typing_extensions import Annotated

from invokeai.app.api.auth_dependencies import AdminUserOrDefault
from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.model_images.model_images_common import ModelImageFileNotFoundException
from invokeai.app.services.model_install.model_install_common import ModelInstallJob
Expand Down Expand Up @@ -155,6 +155,7 @@ def prepare_model_config_for_response(config: AnyModelConfig, dependencies: Type
operation_id="list_model_records",
)
async def list_model_records(
current_user: CurrentUserOrDefault,
base_models: Optional[List[BaseModelType]] = Query(default=None, description="Base models to include"),
model_type: Optional[ModelType] = Query(default=None, description="The type of model to get"),
model_name: Optional[str] = Query(default=None, description="Exact match on the name of the model"),
Expand Down Expand Up @@ -199,11 +200,14 @@ async def list_model_records(
operation_id="list_missing_models",
responses={200: {"description": "List of models with missing files"}},
)
async def list_missing_models() -> ModelsList:
async def list_missing_models(current_user: CurrentUserOrDefault) -> ModelsList:
"""Get models whose files are missing from disk.

These are models that have database entries but their corresponding
weight files have been deleted externally (not via Model Manager).

Available to any authenticated user, not just admins: the frontend's model hooks subtract this
set from the model list so unusable models are kept out of the generation dropdowns.
"""
record_store = ApiDependencies.invoker.services.model_manager.store
models_path = ApiDependencies.invoker.services.configuration.models_path
Expand All @@ -224,6 +228,7 @@ async def list_missing_models() -> ModelsList:
response_model=AnyModelConfig,
)
async def get_model_records_by_attrs(
current_user: CurrentUserOrDefault,
name: str = Query(description="The name of the model"),
type: ModelType = Query(description="The type of the model"),
base: BaseModelType = Query(description="The base model of the model"),
Expand All @@ -245,6 +250,7 @@ async def get_model_records_by_attrs(
response_model=AnyModelConfig,
)
async def get_model_records_by_hash(
current_user: CurrentUserOrDefault,
hash: str = Query(description="The hash of the model"),
) -> AnyModelConfig:
"""Gets a model by its hash. This is useful for recalling models that were deleted and reinstalled,
Expand All @@ -269,6 +275,7 @@ async def get_model_records_by_hash(
},
)
async def get_model_record(
current_user: CurrentUserOrDefault,
key: str = Path(description="Key of the model record to fetch."),
) -> AnyModelConfig:
"""Get a model record"""
Expand Down Expand Up @@ -341,14 +348,23 @@ class FoundModel(BaseModel):
response_model=List[FoundModel],
)
async def scan_for_models(
current_admin: AdminUserOrDefault,
scan_path: str = Query(description="Directory path to search for models", default=None),
) -> List[FoundModel]:
# A single generic error is used for every path-validation failure below, so that the response cannot be
# used as an existence/readability oracle for arbitrary filesystem paths.
scan_failed = HTTPException(
status_code=400,
detail=f"The search path '{scan_path}' could not be scanned",
)

# Guard before constructing a Path: the query param defaults to None and pathlib.Path(None) raises.
if not scan_path:
raise scan_failed

path = pathlib.Path(scan_path)
if not scan_path or not path.is_dir():
raise HTTPException(
status_code=400,
detail=f"The search path '{scan_path}' does not exist or is not directory",
)
if not path.is_dir():
raise scan_failed

search = ModelSearch()
try:
Expand All @@ -372,11 +388,13 @@ async def scan_for_models(
found_model = FoundModel(path=path, is_installed=is_installed)
scan_results.append(found_model)
except Exception as e:
error_type = type(e).__name__
raise HTTPException(
status_code=500,
detail=f"An error occurred while searching the directory: {error_type}",
ApiDependencies.invoker.services.logger.error(
f"Error scanning '{scan_path}' for models: {type(e).__name__}: {e}"
)
# By this point is_dir() has already confirmed the path exists, so a distinct status leaks nothing
# new. Keeping 500 (with the details only in the server log) preserves the caller-error vs
# server-fault distinction for the admin and for retry logic.
raise HTTPException(status_code=500, detail=f"An error occurred while scanning '{scan_path}'")
return scan_results


Expand All @@ -396,6 +414,7 @@ class HuggingFaceModels(BaseModel):
response_model=HuggingFaceModels,
)
async def get_hugging_face_models(
current_admin: AdminUserOrDefault,
hugging_face_repo: str = Query(description="Hugging face repo to search for models", default=None),
) -> HuggingFaceModels:
try:
Expand Down Expand Up @@ -1258,7 +1277,7 @@ def get_is_installed(


@model_manager_router.get("/starter_models", operation_id="get_starter_models", response_model=StarterModelResponse)
async def get_starter_models() -> StarterModelResponse:
async def get_starter_models(current_admin: AdminUserOrDefault) -> StarterModelResponse:
installed_models = ApiDependencies.invoker.services.model_manager.store.search_by_attr()
starter_models = deepcopy(STARTER_MODELS)
starter_bundles = deepcopy(STARTER_BUNDLES)
Expand Down Expand Up @@ -1291,7 +1310,7 @@ async def get_starter_models() -> StarterModelResponse:
response_model=Optional[CacheStats],
summary="Get model manager RAM cache performance statistics.",
)
async def get_stats() -> Optional[CacheStats]:
async def get_stats(current_admin: AdminUserOrDefault) -> Optional[CacheStats]:
"""Return performance statistics on the model manager's RAM cache. Will return null if no models have been loaded."""

return ApiDependencies.invoker.services.model_manager.load.ram_cache.stats
Expand Down Expand Up @@ -1341,7 +1360,7 @@ def reset_token(cls) -> HFTokenStatus:


@model_manager_router.get("/hf_login", operation_id="get_hf_login_status", response_model=HFTokenStatus)
async def get_hf_login_status() -> HFTokenStatus:
async def get_hf_login_status(current_admin: AdminUserOrDefault) -> HFTokenStatus:
token_status = HFTokenHelper.get_status()

if token_status is HFTokenStatus.UNKNOWN:
Expand Down
Loading
Loading