Skip to content

Commit bba207a

Browse files
fix(ui): IP adapter / control adapter model recall for reinstalled models (invoke-ai#8960)
* fix(ui): resolve models by name+base+type when recalling metadata for reinstalled models When a model (IP Adapter, ControlNet, etc.) is deleted and reinstalled, it gets a new UUID key. Previously, metadata recall would fail because it only looked up models by their stored UUID key. Now the recall falls back to searching by name+base+type, allowing reinstalled models with the same name to be correctly resolved. https://claude.ai/code/session_01XYubzMK363BXGTvfJJqFnX * Add hash-based model recall fallback for reinstalled models When a model is deleted and reinstalled, it gets a new UUID key but retains the same BLAKE3 content hash. This adds hash as a middle fallback stage in model resolution (key → hash → name+base+type), making recall more robust. Changes: - Add /api/v2/models/get_by_hash backend endpoint (uses existing search_by_hash from model records store) - Add getModelConfigByHash RTK Query endpoint in frontend - Add hash fallback to both resolveModel and parseModelIdentifier https://claude.ai/code/session_01XYubzMK363BXGTvfJJqFnX * Chore pnpm fix * Chore typegen --------- Co-authored-by: Claude <[email protected]>
1 parent a7b367f commit bba207a

File tree

4 files changed

+139
-7
lines changed

4 files changed

+139
-7
lines changed

invokeai/app/api/routers/model_manager.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,23 @@ async def get_model_records_by_attrs(
193193
return configs[0]
194194

195195

196+
@model_manager_router.get(
197+
"/get_by_hash",
198+
operation_id="get_model_records_by_hash",
199+
response_model=AnyModelConfig,
200+
)
201+
async def get_model_records_by_hash(
202+
hash: str = Query(description="The hash of the model"),
203+
) -> AnyModelConfig:
204+
"""Gets a model by its hash. This is useful for recalling models that were deleted and reinstalled,
205+
as the hash remains stable across reinstallations while the key (UUID) changes."""
206+
configs = ApiDependencies.invoker.services.model_manager.store.search_by_hash(hash)
207+
if not configs:
208+
raise HTTPException(status_code=404, detail="No model found with this hash")
209+
210+
return configs[0]
211+
212+
196213
@model_manager_router.get(
197214
"/i/{key}",
198215
operation_id="get_model_record",

invokeai/frontend/web/src/features/metadata/parsing.tsx

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,7 +1063,8 @@ const CanvasLayers: SingleMetadataHandler<CanvasMetadata> = {
10631063

10641064
for (const entity of parsed.controlLayers) {
10651065
if (entity.controlAdapter.model) {
1066-
await throwIfModelDoesNotExist(entity.controlAdapter.model.key, store);
1066+
const resolvedConfig = await resolveModel(entity.controlAdapter.model, store);
1067+
entity.controlAdapter.model = zModelIdentifierField.parse(resolvedConfig);
10671068
}
10681069
for (const object of entity.objects) {
10691070
if (object.type === 'image' && 'image_name' in object.image) {
@@ -1099,7 +1100,8 @@ const CanvasLayers: SingleMetadataHandler<CanvasMetadata> = {
10991100
await throwIfImageDoesNotExist(refImage.config.image.image_name, store);
11001101
}
11011102
if (refImage.config.model) {
1102-
await throwIfModelDoesNotExist(refImage.config.model.key, store);
1103+
const resolvedConfig = await resolveModel(refImage.config.model, store);
1104+
refImage.config.model = zModelIdentifierField.parse(resolvedConfig);
11031105
}
11041106
}
11051107
}
@@ -1165,7 +1167,9 @@ const RefImages: CollectionMetadataHandler<RefImageState[]> = {
11651167
}
11661168
// FLUX.2 reference images don't have a model field (built-in support)
11671169
if ('model' in refImage.config && refImage.config.model) {
1168-
await throwIfModelDoesNotExist(refImage.config.model.key, store);
1170+
const resolvedConfig = await resolveModel(refImage.config.model, store);
1171+
// Update the model reference in case the key changed (e.g. model was reinstalled)
1172+
refImage.config.model = zModelIdentifierField.parse(resolvedConfig);
11691173
}
11701174
}
11711175

@@ -1534,7 +1538,19 @@ const parseModelIdentifier = async (raw: unknown, store: AppStore, type: ModelTy
15341538
const modelConfig = await req.unwrap();
15351539
return zModelIdentifierField.parse(modelConfig);
15361540
} catch {
1537-
// We'll try to parse the old format identifier next
1541+
// We'll try hash-based lookup next
1542+
}
1543+
1544+
// Try hash-based lookup (handles reinstalled models with new UUID keys)
1545+
try {
1546+
const { hash } = zModelIdentifierField.parse(raw);
1547+
if (hash) {
1548+
const req = store.dispatch(modelsApi.endpoints.getModelConfigByHash.initiate(hash, options));
1549+
const modelConfig = await req.unwrap();
1550+
return zModelIdentifierField.parse(modelConfig);
1551+
}
1552+
} catch {
1553+
// We'll try the old format identifier next
15381554
}
15391555

15401556
// Fall back to old format identifier: model_name, base_model
@@ -1562,10 +1578,44 @@ const throwIfImageDoesNotExist = async (name: string, store: AppStore): Promise<
15621578
}
15631579
};
15641580

1565-
const throwIfModelDoesNotExist = async (key: string, store: AppStore): Promise<void> => {
1581+
/**
1582+
* Resolve a model by key, falling back to hash or name+base+type lookup if the key is not found.
1583+
* This handles the case where a model was deleted and reinstalled (getting a new UUID key).
1584+
* Fallback order: key → hash → name+base+type
1585+
* Returns the resolved model config, or throws if the model cannot be found by any method.
1586+
*/
1587+
const resolveModel = async (
1588+
model: { key: string; hash?: string; name: string; base: string; type: string },
1589+
store: AppStore
1590+
): Promise<AnyModelConfig> => {
1591+
// First try by key (fast path)
1592+
try {
1593+
const req = store.dispatch(modelsApi.endpoints.getModelConfig.initiate(model.key, { subscribe: false }));
1594+
return await req.unwrap();
1595+
} catch {
1596+
// Key not found - try fallback
1597+
}
1598+
1599+
// Second try by hash (most reliable for reinstalled models - hash is content-based)
1600+
if (model.hash) {
1601+
try {
1602+
const req = store.dispatch(modelsApi.endpoints.getModelConfigByHash.initiate(model.hash, { subscribe: false }));
1603+
return await req.unwrap();
1604+
} catch {
1605+
// Hash not found - try next fallback
1606+
}
1607+
}
1608+
1609+
// Last resort: look up by name + base + type
15661610
try {
1567-
await store.dispatch(modelsApi.endpoints.getModelConfig.initiate(key, { subscribe: false }));
1611+
const req = store.dispatch(
1612+
modelsApi.endpoints.getModelConfigByAttrs.initiate(
1613+
{ name: model.name, base: model.base as any, type: model.type as any },
1614+
{ subscribe: false }
1615+
)
1616+
);
1617+
return await req.unwrap();
15681618
} catch {
1569-
throw new Error(`Model with key ${key} does not exist`);
1619+
throw new Error(`Model "${model.name}" (key: ${model.key}) does not exist`);
15701620
}
15711621
};

invokeai/frontend/web/src/services/api/endpoints/models.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,18 @@ export const modelsApi = api.injectEndpoints({
239239
},
240240
serializeQueryArgs: ({ queryArgs }) => `${queryArgs.name}.${queryArgs.base}.${queryArgs.type}`,
241241
}),
242+
getModelConfigByHash: build.query<AnyModelConfig, string>({
243+
query: (hash) => buildModelsUrl(`get_by_hash?${queryString.stringify({ hash })}`),
244+
providesTags: (result) => {
245+
const tags: ApiTagDescription[] = [];
246+
247+
if (result) {
248+
tags.push({ type: 'ModelConfig', id: result.key });
249+
}
250+
251+
return tags;
252+
},
253+
}),
242254
scanFolder: build.query<ScanFolderResponse, ScanFolderArg>({
243255
query: (arg) => {
244256
const folderQueryStr = arg ? queryString.stringify(arg, {}) : '';

invokeai/frontend/web/src/services/api/schema.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,27 @@ export type paths = {
369369
patch?: never;
370370
trace?: never;
371371
};
372+
"/api/v2/models/get_by_hash": {
373+
parameters: {
374+
query?: never;
375+
header?: never;
376+
path?: never;
377+
cookie?: never;
378+
};
379+
/**
380+
* Get Model Records By Hash
381+
* @description Gets a model by its hash. This is useful for recalling models that were deleted and reinstalled,
382+
* as the hash remains stable across reinstallations while the key (UUID) changes.
383+
*/
384+
get: operations["get_model_records_by_hash"];
385+
put?: never;
386+
post?: never;
387+
delete?: never;
388+
options?: never;
389+
head?: never;
390+
patch?: never;
391+
trace?: never;
392+
};
372393
"/api/v2/models/i/{key}": {
373394
parameters: {
374395
query?: never;
@@ -29117,6 +29138,38 @@ export interface operations {
2911729138
};
2911829139
};
2911929140
};
29141+
get_model_records_by_hash: {
29142+
parameters: {
29143+
query: {
29144+
/** @description The hash of the model */
29145+
hash: string;
29146+
};
29147+
header?: never;
29148+
path?: never;
29149+
cookie?: never;
29150+
};
29151+
requestBody?: never;
29152+
responses: {
29153+
/** @description Successful Response */
29154+
200: {
29155+
headers: {
29156+
[name: string]: unknown;
29157+
};
29158+
content: {
29159+
"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"];
29160+
};
29161+
};
29162+
/** @description Validation Error */
29163+
422: {
29164+
headers: {
29165+
[name: string]: unknown;
29166+
};
29167+
content: {
29168+
"application/json": components["schemas"]["HTTPValidationError"];
29169+
};
29170+
};
29171+
};
29172+
};
2912029173
get_model_record: {
2912129174
parameters: {
2912229175
query?: never;

0 commit comments

Comments
 (0)