diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index 9d5b41e7f5f..14b18aac7a6 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -193,6 +193,23 @@ async def get_model_records_by_attrs( return configs[0] +@model_manager_router.get( + "/get_by_hash", + operation_id="get_model_records_by_hash", + response_model=AnyModelConfig, +) +async def get_model_records_by_hash( + 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, + as the hash remains stable across reinstallations while the key (UUID) changes.""" + configs = ApiDependencies.invoker.services.model_manager.store.search_by_hash(hash) + if not configs: + raise HTTPException(status_code=404, detail="No model found with this hash") + + return configs[0] + + @model_manager_router.get( "/i/{key}", operation_id="get_model_record", diff --git a/invokeai/frontend/web/src/features/metadata/parsing.tsx b/invokeai/frontend/web/src/features/metadata/parsing.tsx index c17f18ec93a..7d1d511a3c2 100644 --- a/invokeai/frontend/web/src/features/metadata/parsing.tsx +++ b/invokeai/frontend/web/src/features/metadata/parsing.tsx @@ -1063,7 +1063,8 @@ const CanvasLayers: SingleMetadataHandler = { for (const entity of parsed.controlLayers) { if (entity.controlAdapter.model) { - await throwIfModelDoesNotExist(entity.controlAdapter.model.key, store); + const resolvedConfig = await resolveModel(entity.controlAdapter.model, store); + entity.controlAdapter.model = zModelIdentifierField.parse(resolvedConfig); } for (const object of entity.objects) { if (object.type === 'image' && 'image_name' in object.image) { @@ -1099,7 +1100,8 @@ const CanvasLayers: SingleMetadataHandler = { await throwIfImageDoesNotExist(refImage.config.image.image_name, store); } if (refImage.config.model) { - await throwIfModelDoesNotExist(refImage.config.model.key, store); + const resolvedConfig = await resolveModel(refImage.config.model, store); + refImage.config.model = zModelIdentifierField.parse(resolvedConfig); } } } @@ -1165,7 +1167,9 @@ const RefImages: CollectionMetadataHandler = { } // FLUX.2 reference images don't have a model field (built-in support) if ('model' in refImage.config && refImage.config.model) { - await throwIfModelDoesNotExist(refImage.config.model.key, store); + const resolvedConfig = await resolveModel(refImage.config.model, store); + // Update the model reference in case the key changed (e.g. model was reinstalled) + refImage.config.model = zModelIdentifierField.parse(resolvedConfig); } } @@ -1534,7 +1538,19 @@ const parseModelIdentifier = async (raw: unknown, store: AppStore, type: ModelTy const modelConfig = await req.unwrap(); return zModelIdentifierField.parse(modelConfig); } catch { - // We'll try to parse the old format identifier next + // We'll try hash-based lookup next + } + + // Try hash-based lookup (handles reinstalled models with new UUID keys) + try { + const { hash } = zModelIdentifierField.parse(raw); + if (hash) { + const req = store.dispatch(modelsApi.endpoints.getModelConfigByHash.initiate(hash, options)); + const modelConfig = await req.unwrap(); + return zModelIdentifierField.parse(modelConfig); + } + } catch { + // We'll try the old format identifier next } // Fall back to old format identifier: model_name, base_model @@ -1562,10 +1578,44 @@ const throwIfImageDoesNotExist = async (name: string, store: AppStore): Promise< } }; -const throwIfModelDoesNotExist = async (key: string, store: AppStore): Promise => { +/** + * Resolve a model by key, falling back to hash or name+base+type lookup if the key is not found. + * This handles the case where a model was deleted and reinstalled (getting a new UUID key). + * Fallback order: key → hash → name+base+type + * Returns the resolved model config, or throws if the model cannot be found by any method. + */ +const resolveModel = async ( + model: { key: string; hash?: string; name: string; base: string; type: string }, + store: AppStore +): Promise => { + // First try by key (fast path) + try { + const req = store.dispatch(modelsApi.endpoints.getModelConfig.initiate(model.key, { subscribe: false })); + return await req.unwrap(); + } catch { + // Key not found - try fallback + } + + // Second try by hash (most reliable for reinstalled models - hash is content-based) + if (model.hash) { + try { + const req = store.dispatch(modelsApi.endpoints.getModelConfigByHash.initiate(model.hash, { subscribe: false })); + return await req.unwrap(); + } catch { + // Hash not found - try next fallback + } + } + + // Last resort: look up by name + base + type try { - await store.dispatch(modelsApi.endpoints.getModelConfig.initiate(key, { subscribe: false })); + const req = store.dispatch( + modelsApi.endpoints.getModelConfigByAttrs.initiate( + { name: model.name, base: model.base as any, type: model.type as any }, + { subscribe: false } + ) + ); + return await req.unwrap(); } catch { - throw new Error(`Model with key ${key} does not exist`); + throw new Error(`Model "${model.name}" (key: ${model.key}) does not exist`); } }; diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index f48b5867672..567d63a1000 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -239,6 +239,18 @@ export const modelsApi = api.injectEndpoints({ }, serializeQueryArgs: ({ queryArgs }) => `${queryArgs.name}.${queryArgs.base}.${queryArgs.type}`, }), + getModelConfigByHash: build.query({ + query: (hash) => buildModelsUrl(`get_by_hash?${queryString.stringify({ hash })}`), + providesTags: (result) => { + const tags: ApiTagDescription[] = []; + + if (result) { + tags.push({ type: 'ModelConfig', id: result.key }); + } + + return tags; + }, + }), scanFolder: build.query({ query: (arg) => { const folderQueryStr = arg ? queryString.stringify(arg, {}) : ''; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 2f6af1ee2e5..fc6506ce22b 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -369,6 +369,27 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v2/models/get_by_hash": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Model Records By Hash + * @description Gets a model by its hash. This is useful for recalling models that were deleted and reinstalled, + * as the hash remains stable across reinstallations while the key (UUID) changes. + */ + get: operations["get_model_records_by_hash"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/models/i/{key}": { parameters: { query?: never; @@ -29117,6 +29138,38 @@ export interface operations { }; }; }; + get_model_records_by_hash: { + parameters: { + query: { + /** @description The hash of the model */ + hash: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["Unknown_Config"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_model_record: { parameters: { query?: never;