Compare commits

...

14 Commits

Author SHA1 Message Date
Lincoln Stein
d54adab3c8 Merge branch 'main' into lstein/recall-reference-images 2026-04-20 22:01:56 -04:00
Lincoln Stein
3a1067bbf1 Merge branch 'main' into lstein/recall-reference-images 2026-04-20 16:38:36 -04:00
Lincoln Stein
df5d9d65b1 docs(recall_api): fix merge conflicts and combine advanced and basic recall docs 2026-04-20 16:38:04 -04:00
Lincoln Stein
d9fe4ace26 Merge branch 'main' into lstein/recall-reference-images 2026-04-14 13:32:13 -04:00
Lincoln Stein
3404cd7670 fix(frontend): eliminate ref image doubling from race between Promise.all chains
IP adapters and model-free reference images were dispatched via two
independent Promise.all chains — one with replace:true, the other with
replace:false.  When a previous recall's promises were still in-flight
they could resolve after the clear and re-append stale entries, doubling
the list.  Combine both into a single Promise.all with one replace:true
dispatch so the race is impossible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:28:18 -04:00
Lincoln Stein
5b969af483 Merge branch 'main' into lstein/recall-reference-images 2026-04-14 03:15:45 +02:00
Lincoln Stein
b9a19afb82 feat(recall): add strict mode to clear unset parameters on recall
Add a `strict` query parameter (default false) to POST recall endpoint.
When true, parameters not in the request body are reset: list fields
(loras, control_layers, ip_adapters, reference_images) become [] and
scalar fields become null, so the frontend clears stale state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:14:24 -04:00
Lincoln Stein
c43df36f26 chore(frontend): typegen 2026-04-13 19:40:01 -04:00
Lincoln Stein
6108ca54c4 fix(test): patch ApiDependencies in auth_dependencies to fix recall tests
The patched_dependencies fixture only monkeypatched ApiDependencies in
the recall_parameters module, but the endpoint also resolves
CurrentUserOrDefault via auth_dependencies, which accesses
ApiDependencies.invoker independently. Patch both import sites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:36:47 -04:00
Lincoln Stein
bc9dfb1428 merge: resolve conflicts from main, extend image access check to reference images
Merge main into lstein/recall-reference-images, resolving conflicts in
recall_parameters.py and regenerating openapi.json + schema.ts. Extended
_assert_recall_image_access to also validate reference_images, since they
carry image_name fields that need the same authorization guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:59:47 -04:00
Lincoln Stein
7d1bf270ef chore: fix lint errors and typegen 2026-04-11 23:33:00 -04:00
Lincoln Stein
9412d7b2d6 chore(frontend): typegen 2026-04-11 22:59:29 -04:00
Lincoln Stein
4938d0a3de test(recall): cover loras, control layers, and ip_adapters paths
The original recall_parameters router (PR #8758) shipped without any
unit tests for its three collection fields. This commit backfills that
coverage alongside the reference_images tests added in the previous
commit.

The resolver helpers (resolve_model_name_to_key, load_image_file,
process_controlnet_image) are monkey-patched via module-level attribute
replacement so each test can pin down a specific resolution outcome
without spinning up the model manager or an image-files service. Two
small factory helpers (make_name_to_key_stub / make_load_image_file_stub)
make that ergonomic.

New coverage:

* LoRAs — multi-entry resolution + weight/is_enabled pass-through,
  silent drop on unresolvable names, is_enabled default of True.
* Control layers — ControlNet resolution precedence, fall-through to
  T2I Adapter and Control LoRA in order, missing image gracefully
  warned-and-continued, processed_image attached when the processor
  returns data, unresolvable entries dropped.
* IP Adapters — IPAdapter-before-FluxRedux lookup order, method /
  image_influence pass-through, missing image gracefully warned-and-
  continued, unresolvable entries dropped.
* Combined happy path — full request with prompts + model + all four
  collection fields, verifying every resolved value reaches the
  broadcast payload.
* Main-model drop — an unresolvable main model is scrubbed from the
  broadcast so the frontend never receives a stale model name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:52:12 -04:00
Lincoln Stein
4da9fab888 feat(recall): support model-free reference images in recall API
The recall parameters API previously exposed only `loras`, `control_layers`,
and `ip_adapters`. This meant reference images used by architectures that
feed images directly into the main model — FLUX.2 Klein, FLUX Kontext, and
Qwen Image Edit — could not be sent through the recall endpoint at all:
they have no adapter model to resolve, so they could not ride in the
`ip_adapters` list.

This change adds a new `reference_images` field on RecallParameter that
carries only an `image_name`. The backend validates the file exists in
outputs/images and forwards the resolved metadata (width/height) in the
broadcast event. The frontend's recall handler picks the right config type
(`flux2_reference_image` / `flux_kontext_reference_image` / `ip_adapter`
fallback) via getDefaultRefImageConfig() based on the currently-selected
main model, matching the behavior of a manual drag-and-drop, and dispatches
`refImagesRecalled` with replace:false so these append rather than clobber
any adapters already applied in the same event.

Also consolidates the two existing docs under docs/contributing/RECALL_PARAMETERS/
(RECALL_PARAMETERS_API.md and RECALL_API_LORAS_CONTROLNETS_IMAGES.md) into
a single RECALL_PARAMETERS_API.md that documents the full request schema
including the new field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:08:53 -04:00
7 changed files with 8769 additions and 1023 deletions

View File

@@ -1,377 +0,0 @@
---
title: Recall Parameters API (Advanced)
---
# Recall Parameters API - LoRAs, ControlNets, and IP Adapters with Images
The Recall Parameters API supports recalling LoRAs, ControlNets (including T2I Adapters and Control LoRAs), and IP Adapters along with their associated weights and settings. Control Layers and IP Adapters can now include image references from the `INVOKEAI_ROOT/outputs/images` directory for fully functional control and image prompt functionality.
## Key Features
✅ **LoRAs**: Fully functional - adds to UI, queries model configs, applies weights<br />
✅ **Control Layers**: Full support with optional images from outputs/images<br />
✅ **IP Adapters**: Full support with optional reference images from outputs/images<br />
✅ **Model Name Resolution**: Automatic lookup from human-readable names to internal keys<br />
✅ **Image Validation**: Backend validates that image files exist before sending
## Endpoints
### POST `/api/v1/recall/{queue_id}`
Updates recallable parameters for the frontend, including LoRAs, control adapters, and IP adapters with optional images.
**Path Parameters:**
- `queue_id` (string): The queue ID to associate parameters with (typically "default")
**Request Body:**
All fields are optional. Include only the parameters you want to update.
```typescript
{
// Standard parameters
positive_prompt?: string;
negative_prompt?: string;
model?: string; // Model name or key
steps?: number;
cfg_scale?: number;
width?: number;
height?: number;
seed?: number;
// ... other standard parameters
// LoRAs
loras?: Array<{
model_name: string; // LoRA model name
weight?: number; // Default: 0.75, Range: -10 to 10
is_enabled?: boolean; // Default: true
}>;
// Control Layers (ControlNet, T2I Adapter, Control LoRA)
control_layers?: Array<{
model_name: string; // Control adapter model name
image_name?: string; // Optional image filename from outputs/images
weight?: number; // Default: 1.0, Range: -1 to 2
begin_step_percent?: number; // Default: 0.0, Range: 0 to 1
end_step_percent?: number; // Default: 1.0, Range: 0 to 1
control_mode?: "balanced" | "more_prompt" | "more_control"; // ControlNet only
}>;
// IP Adapters
ip_adapters?: Array<{
model_name: string; // IP Adapter model name
image_name?: string; // Optional reference image filename from outputs/images
weight?: number; // Default: 1.0, Range: -1 to 2
begin_step_percent?: number; // Default: 0.0, Range: 0 to 1
end_step_percent?: number; // Default: 1.0, Range: 0 to 1
method?: "full" | "style" | "composition"; // Default: "full"
influence?: "Lowest" | "Low" | "Medium" | "High" | "Highest"; // Flux Redux only; default: "highest"
}>;
}
```
## Model Name Resolution
The backend automatically resolves model names to their internal keys:
1. **Main Models**: Resolved from the name to the model key
2. **LoRAs**: Searched in the LoRA model database
3. **Control Adapters**: Tried in order - ControlNet → T2I Adapter → Control LoRA
4. **IP Adapters**: Searched in the IP Adapter model database
Models that cannot be resolved are skipped with a warning in the logs.
## Image File Handling
### Image Path Resolution
When you specify an `image_name`, the backend:
1. Constructs the full path: `{INVOKEAI_ROOT}/outputs/images/{image_name}`
2. Validates that the file exists
3. Includes the image reference in the event sent to the frontend
4. Logs whether the image was found or not
### Image Naming
Images should be referenced by their filename as it appears in the outputs/images directory:
- ✅ Correct: `"image_name": "example.png"`
- ✅ Correct: `"image_name": "my_control_image_20240110.jpg"`
- ❌ Incorrect: `"image_name": "outputs/images/example.png"` (use relative filename only)
- ❌ Incorrect: `"image_name": "/full/path/to/example.png"` (use relative filename only)
## Frontend Behavior
### LoRAs
- **Fully Supported**: LoRAs are immediately added to the LoRA list in the UI
- Existing LoRAs are cleared before adding new ones
- Each LoRA's model config is fetched and applied with the specified weight
- LoRAs appear in the LoRA selector panel
### Control Layers with Images
- **Fully Supported**: Control layers now support images from outputs/images
- Configuration includes model, weights, step percentages, and image reference
- Image availability is logged in frontend console
- Images can be used to create actual control layers through the UI
### IP Adapters with Images
- **Fully Supported**: IP Adapters now support reference images from outputs/images
- Configuration includes model, weights, step percentages, method, and image reference
- Image availability is logged in frontend console
- Images can be used to create actual reference image layers through the UI
## Examples
### 1. Add LoRAs Only
```bash
curl -X POST http://localhost:9090/api/v1/recall/default \
-H "Content-Type: application/json" \
-d '{
"loras": [
{
"model_name": "add-detail-xl",
"weight": 0.8,
"is_enabled": true
},
{
"model_name": "sd_xl_offset_example-lora_1.0",
"weight": 0.5,
"is_enabled": true
}
]
}'
```
### 2. Configure Control Layers with Image
Replace `my_control_image.png` with an actual image filename from your outputs/images directory.
```bash
curl -X POST http://localhost:9090/api/v1/recall/default \
-H "Content-Type: application/json" \
-d '{
"control_layers": [
{
"model_name": "controlnet-canny-sdxl-1.0",
"image_name": "my_control_image.png",
"weight": 0.75,
"begin_step_percent": 0.0,
"end_step_percent": 0.8,
"control_mode": "balanced"
}
]
}'
```
### 3. Configure IP Adapters with Reference Image
Replace `reference_face.png` with an actual image filename from your outputs/images directory.
```bash
curl -X POST http://localhost:9090/api/v1/recall/default \
-H "Content-Type: application/json" \
-d '{
"ip_adapters": [
{
"model_name": "ip-adapter-plus-face_sd15",
"image_name": "reference_face.png",
"weight": 0.7,
"begin_step_percent": 0.0,
"end_step_percent": 1.0,
"method": "composition"
}
]
}'
```
### 4. Complete Configuration with All Features
```bash
curl -X POST http://localhost:9090/api/v1/recall/default \
-H "Content-Type: application/json" \
-d '{
"positive_prompt": "masterpiece, detailed photo with specific style",
"negative_prompt": "blurry, low quality",
"model": "FLUX Schnell",
"steps": 25,
"cfg_scale": 8.0,
"width": 1024,
"height": 768,
"seed": 42,
"loras": [
{
"model_name": "add-detail-xl",
"weight": 0.6,
"is_enabled": true
}
],
"control_layers": [
{
"model_name": "controlnet-depth-sdxl-1.0",
"image_name": "depth_map.png",
"weight": 1.0,
"begin_step_percent": 0.0,
"end_step_percent": 0.7
}
],
"ip_adapters": [
{
"model_name": "ip-adapter-plus-face_sd15",
"image_name": "style_reference.png",
"weight": 0.5,
"begin_step_percent": 0.0,
"end_step_percent": 1.0,
"method": "style"
}
]
}'
```
## Response Format
```json
{
"status": "success",
"queue_id": "default",
"updated_count": 15,
"parameters": {
"positive_prompt": "...",
"steps": 25,
"loras": [
{
"model_key": "abc123...",
"weight": 0.6,
"is_enabled": true
}
],
"control_layers": [
{
"model_key": "controlnet-xyz...",
"weight": 1.0,
"image": {
"image_name": "depth_map.png"
}
}
],
"ip_adapters": [
{
"model_key": "ip-adapter-xyz...",
"weight": 0.5,
"image": {
"image_name": "style_reference.png"
}
}
]
}
}
```
## WebSocket Events
When parameters are updated, a `recall_parameters_updated` event is emitted via WebSocket to the queue room. The frontend automatically:
1. Applies standard parameters (prompts, steps, dimensions, etc.)
2. Loads and adds LoRAs to the LoRA list
3. Logs control layer and IP adapter configurations with image information
4. Makes image references available for manual canvas/reference image creation
## Logging
### Backend Logs
Backend logs show:
- Model name → key resolution (success/failure)
- Image file validation (found/not found)
- Parameter storage confirmation
- Event emission status
Example log messages:
```
INFO: Resolved ControlNet model name 'controlnet-canny-sdxl-1.0' to key 'controlnet-xyz...'
INFO: Found image file: depth_map.png
INFO: Updated 12 recall parameters for queue default
INFO: Resolved 1 LoRA(s)
INFO: Resolved 1 control layer(s)
INFO: Resolved 1 IP adapter(s)
```
### Frontend Logs
Frontend logs (check browser console):
- Set `localStorage.ROARR_FILTER = 'debug'` to see all debug messages
- Look for messages from the `events` namespace
- LoRA loading, model resolution, and parameter application are logged
Example log messages:
```
INFO: Applied 5 recall parameters to store
INFO: Received 1 control layer(s) with image support
INFO: Control layer 1: controlnet-xyz... (weight: 0.75, image: depth_map.png)
DEBUG: Control layer 1 image available at: outputs/images/depth_map.png
INFO: Received 1 IP adapter(s) with image support
INFO: IP adapter 1: ip-adapter-xyz... (weight: 0.7, image: style_reference.png)
DEBUG: IP adapter 1 image available at: outputs/images/style_reference.png
```
## Limitations
1. **Canvas Integration**: Control layers and IP adapters with images are currently logged but not automatically added to canvas layers
- Users can view the configuration and manually create canvas layers with the provided images
- Future enhancement: Auto-create canvas layers with stored images
2. **Model Availability**: Models must be installed in InvokeAI before they can be recalled
3. **Image Availability**: Images must exist in the outputs/images directory
- Missing images are logged as warnings but don't fail the request
- Other parameters are still applied even if images are missing
4. **Image URLs**: Only local filenames from outputs/images are supported
- Remote image URLs are not currently supported
## Testing
Use the provided test script:
```bash
./test_recall_loras_controlnets.sh
```
This will test:
- LoRA addition with multiple models
- Control layer configuration with image references
- IP adapter configuration with image references
- Combined parameter updates with all features
Note: Update the image names in the test script to match actual images in your outputs/images directory.
## Troubleshooting
### Images Not Found
If you see "Image file not found" in the logs:
1. Verify the image filename matches exactly (case-sensitive)
2. Ensure the image is in `{INVOKEAI_ROOT}/outputs/images/`
3. Check that the filename doesn't include the `outputs/images/` prefix
### Models Not Found
If you see "Could not find model" messages:
1. Verify the model name matches exactly (case-sensitive)
2. Ensure the model is installed in InvokeAI
3. Check the model name using the models browser in the UI
### Event Not Received
If the frontend doesn't receive the event:
1. Check browser console for connection errors
2. Verify the queue_id matches the frontend's queue (usually "default")
3. Check backend logs for event emission errors
## Future Enhancements
Potential improvements:
1. Auto-create canvas layers with provided control layer images
2. Auto-create reference image layers with provided IP adapter images
3. Support for image URLs
4. Batch operations for multiple queue IDs
5. Image upload capability (accept base64 or file upload)

View File

@@ -4,29 +4,54 @@ title: Recall Parameters API
## Overview
A new REST API endpoint has been added to the InvokeAI backend that allows programmatic updates to recallable parameters from another process. This enables external applications or scripts to modify frontend parameters like prompts, models, and step counts via HTTP requests.
The Recall Parameters API is a REST endpoint on the InvokeAI backend that
lets external processes set recallable generation parameters on the
frontend. Supported parameters include:
When parameters are updated via the API, the backend automatically broadcasts a WebSocket event to all connected frontend clients subscribed to that queue, causing them to update immediately.
- Core text and numeric parameters (prompts, model, steps, CFG, dimensions, seed, ...)
- LoRAs
- Control Layers (ControlNet, T2I Adapter, Control LoRA) with optional control images
- IP Adapters and FLUX Redux reference images with optional images
- Model-free reference images (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit)
When parameters are updated via the API, the backend stores them in client
state persistence for the target queue and broadcasts a `recall_parameters_updated`
WebSocket event. Any frontend client subscribed to that queue applies the
new values immediately — no manual reload required.
Typical use cases:
- An external image browser that wants to "recall" or "remix" the
generation parameters saved into a PNG's metadata.
- A script that pre-populates parameters before the user runs generation.
- Automated testing or batch workflows that want to reuse existing model
and adapter configurations.
## How It Works
1. **API Request**: External application sends a POST request with parameters to update
2. **Storage**: Parameters are stored in client state persistence, associated with a queue ID
3. **Broadcast**: A WebSocket event (`recall_parameters_updated`) is emitted to all frontend clients listening to that queue
4. **Frontend Update**: Connected frontend clients receive the event and can process the updated parameters
5. **Immediate Display**: The frontend UI updates automatically with the new values
This means if you have the InvokeAI frontend open in a browser, updating parameters via the API will instantly reflect on the screen without any manual action needed.
1. **API request** — your client POSTs a JSON body of parameters to
`/api/v1/recall/{queue_id}`.
2. **Storage** — non-null parameters are stored under
`recall_*` keys in the client state persistence service, scoped to the
given `queue_id`.
3. **Resolution** — models are resolved from human-readable names to the
internal model keys used by the frontend, and image filenames are
validated against `{INVOKEAI_ROOT}/outputs/images`.
4. **Broadcast** — a `recall_parameters_updated` event is emitted on the
websocket room for `queue_id`.
5. **Frontend update** — any connected client subscribed to that queue
applies the update to its Redux store, so UI fields, LoRAs, control
layers, IP adapters, and reference images all populate immediately.
## Endpoint
**Base URL**: `http://localhost:9090/api/v1/recall/{queue_id}`
**Base URL:** `http://localhost:9090/api/v1/recall/{queue_id}`
## POST - Update Recall Parameters
The queue id is usually `default`.
Updates recallable parameters for a given queue ID.
### POST — Update Recall Parameters
### Request
Updates recallable parameters for the given `queue_id`.
```http
POST /api/v1/recall/{queue_id}
@@ -44,11 +69,25 @@ Content-Type: application/json
}
```
The queue id is usually "default".
All parameters are optional — only send the fields you want to update.
### Parameters
### GET — Retrieve Recall Parameters
All parameters are optional. Only provide the parameters you want to update:
```http
GET /api/v1/recall/{queue_id}
```
```json
{
"status": "success",
"queue_id": "queue_123",
"note": "Use the frontend to access stored recall parameters, or set specific parameters using POST"
}
```
## Request Schema
### Core parameters
| Parameter | Type | Description |
|-----------|------|-------------|
@@ -67,60 +106,130 @@ All parameters are optional. Only provide the parameters you want to update:
| `width` | integer | Image width in pixels (≥64) |
| `height` | integer | Image height in pixels (≥64) |
| `seed` | integer | Random seed (≥0) |
| `denoise_strength` | number | Denoising strength (0-1) |
| `refiner_denoise_start` | number | Refiner denoising start (0-1) |
| `denoise_strength` | number | Denoising strength (01) |
| `refiner_denoise_start` | number | Refiner denoising start (01) |
| `clip_skip` | integer | CLIP skip layers (≥0) |
| `seamless_x` | boolean | Enable seamless X tiling |
| `seamless_y` | boolean | Enable seamless Y tiling |
| `refiner_positive_aesthetic_score` | number | Refiner positive aesthetic score |
| `refiner_negative_aesthetic_score` | number | Refiner negative aesthetic score |
### Response
### Collection parameters
```json
```typescript
{
"status": "success",
"queue_id": "queue_123",
"updated_count": 7,
"parameters": {
"positive_prompt": "a beautiful landscape",
"negative_prompt": "blurry, low quality",
"model": "sd-1.5",
"steps": 20,
"cfg_scale": 7.5,
"width": 512,
"height": 512,
"seed": 12345
}
// LoRAs
loras?: Array<{
model_name: string; // LoRA model name
weight?: number; // Default: 0.75, Range: -10 to 10
is_enabled?: boolean; // Default: true
}>;
// Control Layers (ControlNet, T2I Adapter, Control LoRA)
control_layers?: Array<{
model_name: string; // Control adapter model name
image_name?: string; // Optional image filename from outputs/images
weight?: number; // Default: 1.0, Range: -1 to 2
begin_step_percent?: number; // Default: 0.0, Range: 0 to 1
end_step_percent?: number; // Default: 1.0, Range: 0 to 1
control_mode?: "balanced" | "more_prompt" | "more_control"; // ControlNet only
}>;
// IP Adapters (includes FLUX Redux)
ip_adapters?: Array<{
model_name: string; // IP Adapter / FLUX Redux model name
image_name?: string; // Optional reference image filename from outputs/images
weight?: number; // Default: 1.0, Range: -1 to 2
begin_step_percent?: number; // Default: 0.0, Range: 0 to 1
end_step_percent?: number; // Default: 1.0, Range: 0 to 1
method?: "full" | "style" | "composition"; // Default: "full"
image_influence?: "lowest" | "low" | "medium" | "high" | "highest"; // FLUX Redux only
}>;
// Model-free reference images (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit)
reference_images?: Array<{
image_name: string; // Reference image filename from outputs/images
}>;
}
```
## GET - Retrieve Recall Parameters
## Model Name Resolution
Retrieves metadata about stored recall parameters.
The backend resolves model names to their internal keys:
### Request
1. **Main models** — resolved from the name to the model key.
2. **LoRAs** — searched in the LoRA model database.
3. **Control adapters** — tried in order: ControlNet → T2I Adapter → Control LoRA.
4. **IP Adapters** — searched in the IP Adapter database; falls back to FLUX Redux.
```http
GET /api/v1/recall/{queue_id}
```
Models that cannot be resolved are skipped with a warning in the logs —
the rest of the parameters are still applied.
### Response
## Image File Handling
```json
{
"status": "success",
"queue_id": "queue_123",
"note": "Use the frontend to access stored recall parameters, or set specific parameters using POST"
}
```
When an `image_name` is supplied, the backend:
1. Resolves `{INVOKEAI_ROOT}/outputs/images/{image_name}` via the image
files service (which also validates the path).
2. Opens the image to extract width/height.
3. Includes the image metadata in the event sent to the frontend.
4. Logs whether the image was found.
Images must be referenced by their filename as it appears in the
outputs/images directory:
- ✅ `"image_name": "example.png"`
- ✅ `"image_name": "my_control_image_20240110.jpg"`
- ❌ `"image_name": "outputs/images/example.png"` (no prefix)
- ❌ `"image_name": "/full/path/to/example.png"` (no absolute paths)
Missing images are logged as warnings but **do not** fail the request —
remaining parameters are still applied.
## Feature Details
### LoRAs
- Existing LoRAs are cleared before new ones are added.
- Each LoRA's model config is fetched and applied with the specified weight.
- LoRAs appear in the LoRA selector panel.
### Control Layers
- Fully supported with optional images from `outputs/images`.
- Configuration includes model, weights, step percentages, control mode,
and an image reference.
- Image availability is logged in the frontend console.
### IP Adapters / FLUX Redux
- Reference images loaded from `outputs/images` are validated and passed
through.
- Configuration includes model, weights, step percentages, method, and an
image reference.
- FLUX Redux uses `image_influence` instead of a numeric weight.
### Model-free reference images
Used by architectures that consume a reference image directly, with no
separate adapter model:
- **FLUX.2 Klein** — built-in reference image support.
- **FLUX Kontext** — reference image associated with the main model.
- **Qwen Image Edit** — reference image associated with the main model.
Because there is no adapter model to resolve, these entries carry only
`image_name`. When the frontend receives them, it picks the appropriate
config flavor (`flux2_reference_image`, `flux_kontext_reference_image`,
or `qwen_image_reference_image`) based on the currently-selected main
model, matching the behavior of a manual drag-and-drop.
## Usage Examples
### Using cURL
### cURL
```bash
# Update prompts and model
# Core parameters
curl -X POST http://localhost:9090/api/v1/recall/default \
-H "Content-Type: application/json" \
-d '{
@@ -130,81 +239,285 @@ curl -X POST http://localhost:9090/api/v1/recall/default \
"steps": 30
}'
# Update just the seed
# Just the seed
curl -X POST http://localhost:9090/api/v1/recall/default \
-H "Content-Type: application/json" \
-d '{"seed": 99999}'
```
### Using Python
### LoRAs only
```bash
curl -X POST http://localhost:9090/api/v1/recall/default \
-H "Content-Type: application/json" \
-d '{
"loras": [
{"model_name": "add-detail-xl", "weight": 0.8, "is_enabled": true},
{"model_name": "sd_xl_offset_example-lora_1.0", "weight": 0.5}
]
}'
```
### Control layers with an image
```bash
curl -X POST http://localhost:9090/api/v1/recall/default \
-H "Content-Type: application/json" \
-d '{
"control_layers": [
{
"model_name": "controlnet-canny-sdxl-1.0",
"image_name": "my_control_image.png",
"weight": 0.75,
"begin_step_percent": 0.0,
"end_step_percent": 0.8,
"control_mode": "balanced"
}
]
}'
```
### IP adapters with a reference image
```bash
curl -X POST http://localhost:9090/api/v1/recall/default \
-H "Content-Type: application/json" \
-d '{
"ip_adapters": [
{
"model_name": "ip-adapter-plus-face_sd15",
"image_name": "reference_face.png",
"weight": 0.7,
"method": "composition"
}
]
}'
```
### Model-free reference images (FLUX.2 Klein / FLUX Kontext / Qwen Image Edit)
```bash
curl -X POST http://localhost:9090/api/v1/recall/default \
-H "Content-Type: application/json" \
-d '{
"model": "FLUX.2 Klein",
"reference_images": [
{"image_name": "style_reference.png"}
]
}'
```
### Complete configuration
```bash
curl -X POST http://localhost:9090/api/v1/recall/default \
-H "Content-Type: application/json" \
-d '{
"positive_prompt": "masterpiece, detailed photo with specific style",
"negative_prompt": "blurry, low quality",
"model": "FLUX Schnell",
"steps": 25,
"cfg_scale": 8.0,
"width": 1024,
"height": 768,
"seed": 42,
"loras": [
{"model_name": "add-detail-xl", "weight": 0.6}
],
"control_layers": [
{
"model_name": "controlnet-depth-sdxl-1.0",
"image_name": "depth_map.png",
"weight": 1.0,
"end_step_percent": 0.7
}
],
"ip_adapters": [
{
"model_name": "ip-adapter-plus-face_sd15",
"image_name": "style_reference.png",
"weight": 0.5,
"method": "style"
}
]
}'
```
### Python
```python
import requests
import json
# Configuration
API_URL = "http://localhost:9090/api/v1/recall/default"
# Update multiple parameters
params = {
"positive_prompt": "a serene forest",
"negative_prompt": "people, buildings",
"steps": 25,
"cfg_scale": 7.0,
"seed": 42
"seed": 42,
}
response = requests.post(API_URL, json=params)
result = response.json()
print(f"Status: {result['status']}")
print(f"Updated {result['updated_count']} parameters")
print(json.dumps(result['parameters'], indent=2))
```
### Using Node.js/JavaScript
### JavaScript
```javascript
const API_URL = 'http://localhost:9090/api/v1/recall/default';
const params = {
positive_prompt: 'a beautiful sunset',
negative_prompt: 'blurry',
steps: 20,
width: 768,
height: 768,
seed: 12345
};
fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
body: JSON.stringify({
positive_prompt: 'a beautiful sunset',
steps: 20,
width: 768,
height: 768,
seed: 12345,
}),
})
.then(res => res.json())
.then(data => console.log(data));
.then((res) => res.json())
.then((data) => console.log(data));
```
## Response Format
```json
{
"status": "success",
"queue_id": "default",
"updated_count": 15,
"parameters": {
"positive_prompt": "...",
"steps": 25,
"loras": [
{"model_key": "abc123...", "weight": 0.6, "is_enabled": true}
],
"control_layers": [
{
"model_key": "controlnet-xyz...",
"weight": 1.0,
"image": {"image_name": "depth_map.png", "width": 1024, "height": 768}
}
],
"ip_adapters": [
{
"model_key": "ip-adapter-xyz...",
"weight": 0.5,
"image": {"image_name": "style_reference.png", "width": 1024, "height": 1024}
}
],
"reference_images": [
{"image": {"image_name": "style_reference.png", "width": 1024, "height": 1024}}
]
}
}
```
## WebSocket Events
Parameter updates emit a `recall_parameters_updated` event to the queue
room. Connected frontend clients automatically:
1. Apply standard parameters (prompts, steps, dimensions, etc.).
2. Load and add LoRAs to the LoRA list.
3. Apply control-layer configurations.
4. Apply IP Adapter / FLUX Redux configurations with their images.
5. Append model-free reference images, using the config flavor that
matches the currently-selected main model.
## Error Handling
- **400 Bad Request** — invalid parameters or parameter values.
- **500 Internal Server Error** — server-side storage or retrieval failure.
Errors include detailed messages. Missing images and unresolved model
names are **not** errors — they are logged and the remaining parameters
are still applied.
## Logging
### Backend
```
INFO: Resolved ControlNet model name 'controlnet-canny-sdxl-1.0' to key 'controlnet-xyz...'
INFO: Found image file: depth_map.png (1024x768)
INFO: Updated 12 recall parameters for queue default
INFO: Resolved 1 LoRA(s)
INFO: Resolved 1 control layer(s)
INFO: Resolved 1 IP adapter(s)
INFO: Resolved 1 reference image(s)
```
### Frontend
Set `localStorage.ROARR_FILTER = 'debug'` in the browser to see all debug
messages under the `events` namespace.
```
INFO: Applied 5 recall parameters to store
INFO: Applied 1 IP adapter(s), replacing existing list
INFO: Applied 1 model-free reference image(s)
DEBUG: Built IP adapter ref image state: ip-adapter-xyz... (weight: 0.7)
DEBUG: IP adapter image: outputs/images/depth_map.png (1024x768)
```
## Implementation Details
- Parameters are stored in the client state persistence service, using keys prefixed with `recall_`
- The parameters are associated with a `queue_id`, allowing multiple concurrent sessions to maintain separate parameter sets
- Only non-null parameters are processed and stored
- The endpoint provides validation for numeric ranges (e.g., steps ≥ 1, dimensions ≥ 64)
- All parameter values are JSON-serialized for storage
- When parameter values are changed, the backend generates a web sockets event that the frontend listens to.
- Parameters are stored in the client state persistence service under
`recall_*` keys, scoped to the `queue_id`.
- Numeric validation runs at the FastAPI layer (e.g. `steps ≥ 1`, `width ≥ 64`).
- Only non-null parameters are processed, stored, and broadcast.
- Model-key resolution runs **after** the raw parameters are stored, so
an unresolvable model name simply drops out of the broadcast but does
not corrupt the persisted state.
- The broadcast payload contains resolved model keys and image metadata
(width/height) so the frontend can populate its store without extra
round-trips.
## Integration with Frontend
## Troubleshooting
The stored parameters can be accessed by the frontend through the
existing client state API or by implementing hooks that read from the
recall parameter storage. This allows external applications to
pre-populate generation parameters before the user initiates image
generation.
### Image not found
## Error Handling
If you see "Image file not found" in the logs:
- **400 Bad Request**: Invalid parameters or parameter values
- **500 Internal Server Error**: Server-side error storing or retrieving parameters
1. Verify the filename matches exactly (case-sensitive).
2. Ensure the image is in `{INVOKEAI_ROOT}/outputs/images/`.
3. Check that the filename does not include the `outputs/images/` prefix.
Errors include detailed messages explaining what went wrong.
### Model not found
If you see "Could not find model":
1. Verify the model name matches exactly (case-sensitive).
2. Ensure the model is installed.
3. Check the name via the Models Manager panel.
### Event not received
1. Check the browser console for socket connection errors.
2. Verify the `queue_id` matches the frontend's queue (usually `default`).
3. Check backend logs for event emission errors.
## Limitations
- **Model availability** — models referenced in the payload must be installed.
- **Image availability** — images must exist in `outputs/images`; remote
URLs are not supported.
- **Canvas auto-layer creation** — control layers and IP adapters with
images populate the recall state, but creating a canvas layer from
them still happens through the UI.
## Future enhancements
Potential improvements not yet implemented:
1. Auto-create canvas layers from control-layer images in the payload.
2. Auto-create reference-image layers from IP Adapter images in the payload.
3. Support remote image URLs in addition to local `outputs/images` filenames.
4. Image upload capability (accept base64 or file upload directly via the API).
5. Batch operations that target multiple `queue_id`s in a single request.

View File

@@ -3,7 +3,7 @@
import json
from typing import Any, Literal, Optional
from fastapi import Body, HTTPException, Path
from fastapi import Body, HTTPException, Path, Query
from fastapi.routing import APIRouter
from pydantic import BaseModel, ConfigDict, Field
@@ -58,6 +58,20 @@ class IPAdapterRecallParameter(BaseModel):
)
class ReferenceImageRecallParameter(BaseModel):
"""Global reference-image configuration for recall.
Used for reference images that feed directly into the main model rather
than through a separate IP-Adapter / ControlNet model — for example
FLUX.2 Klein, FLUX Kontext, and Qwen Image Edit. The receiving frontend
picks the correct config type (``flux2_reference_image`` /
``qwen_image_reference_image`` / ``flux_kontext_reference_image``) based
on the currently-selected main model.
"""
image_name: str = Field(description="The filename of the reference image in outputs/images")
class RecallParameter(BaseModel):
"""Request model for updating recallable parameters."""
@@ -105,6 +119,14 @@ class RecallParameter(BaseModel):
ip_adapters: Optional[list[IPAdapterRecallParameter]] = Field(
None, description="List of IP Adapters with their settings"
)
reference_images: Optional[list[ReferenceImageRecallParameter]] = Field(
None,
description=(
"List of model-free reference images for architectures that consume reference "
"images directly (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit). The frontend "
"picks the correct config type based on the currently-selected main model."
),
)
def resolve_model_name_to_key(model_name: str, model_type: ModelType = ModelType.Main) -> Optional[str]:
@@ -292,11 +314,43 @@ def resolve_ip_adapter_models(ip_adapters: list[IPAdapterRecallParameter]) -> li
return resolved_adapters
def resolve_reference_images(
reference_images: list[ReferenceImageRecallParameter],
) -> list[dict[str, Any]]:
"""
Validate model-free reference images and build the configuration list.
Unlike IP Adapters and ControlNets, these reference images are consumed
directly by the main model (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit),
so there is no adapter-model name to resolve. We simply verify that each
referenced file exists in ``outputs/images`` and pass the image metadata
through to the frontend.
Args:
reference_images: List of reference-image recall parameters
Returns:
List of reference-image configurations with resolved image metadata.
Entries whose image file cannot be loaded are dropped with a warning.
"""
logger = ApiDependencies.invoker.services.logger
resolved: list[dict[str, Any]] = []
for ref in reference_images:
image_data = load_image_file(ref.image_name)
if image_data is None:
logger.warning(f"Skipping reference image '{ref.image_name}' - file not found")
continue
resolved.append({"image": image_data})
return resolved
def _assert_recall_image_access(parameters: "RecallParameter", current_user: CurrentUserOrDefault) -> None:
"""Validate that the caller can read every image referenced in the recall parameters.
Control layers and IP adapters may reference image_name fields. Without this
check an attacker who knows another user's image UUID could use the recall
Control layers, IP adapters, and reference images may reference image_name fields.
Without this check an attacker who knows another user's image UUID could use the recall
endpoint to extract image dimensions and — for ControlNet preprocessors — mint
a derived processed image they can then fetch.
"""
@@ -311,6 +365,10 @@ def _assert_recall_image_access(parameters: "RecallParameter", current_user: Cur
for adapter in parameters.ip_adapters:
if adapter.image_name is not None:
image_names.append(adapter.image_name)
if parameters.reference_images:
for ref in parameters.reference_images:
if ref.image_name is not None:
image_names.append(ref.image_name)
if not image_names:
return
@@ -346,6 +404,10 @@ async def update_recall_parameters(
current_user: CurrentUserOrDefault,
queue_id: str = Path(..., description="The queue id to perform this operation on"),
parameters: RecallParameter = Body(..., description="Recall parameters to update"),
strict: bool = Query(
default=False,
description="When true, parameters not included in the request are reset to their defaults (cleared).",
),
) -> dict[str, Any]:
"""
Update recallable parameters that can be recalled on the frontend.
@@ -357,21 +419,23 @@ async def update_recall_parameters(
Args:
queue_id: The queue ID to associate these parameters with
parameters: The RecallParameter object containing the parameters to update
strict: When true, parameters not included in the request body are reset
to their defaults (cleared on the frontend). Defaults to false,
which preserves the existing behaviour of only updating the
parameters that are explicitly provided.
Returns:
A dictionary containing the updated parameters and status
Example:
POST /api/v1/recall/{queue_id}
POST /api/v1/recall/{queue_id}?strict=true
{
"positive_prompt": "a beautiful landscape",
"model": "sd-1.5",
"steps": 20,
"cfg_scale": 7.5,
"width": 512,
"height": 512,
"seed": 12345
"steps": 20
}
# In strict mode, all other parameters (reference_images, loras, etc.)
# are cleared. In non-strict mode (default) they would be left as-is.
"""
logger = ApiDependencies.invoker.services.logger
@@ -380,8 +444,18 @@ async def update_recall_parameters(
_assert_recall_image_access(parameters, current_user)
try:
# Get only the parameters that were actually provided (non-None values)
provided_params = {k: v for k, v in parameters.model_dump().items() if v is not None}
# In strict mode, include all parameters so the frontend clears anything
# not explicitly provided. List-typed fields use [] instead of None so
# the frontend sees an empty collection rather than a null it might skip.
if strict:
_list_fields = {
name for name, field in RecallParameter.model_fields.items() if "list" in str(field.annotation).lower()
}
provided_params = {
k: ([] if v is None and k in _list_fields else v) for k, v in parameters.model_dump().items()
}
else:
provided_params = {k: v for k, v in parameters.model_dump().items() if v is not None}
if not provided_params:
return {"status": "no_parameters_provided", "updated_count": 0}
@@ -442,6 +516,14 @@ async def update_recall_parameters(
provided_params["ip_adapters"] = resolved_adapters
logger.info(f"Resolved {len(resolved_adapters)} IP adapter(s)")
# Process model-free reference images if provided
if "reference_images" in provided_params:
reference_images_param = parameters.reference_images
if reference_images_param is not None:
resolved_refs = resolve_reference_images(reference_images_param)
provided_params["reference_images"] = resolved_refs
logger.info(f"Resolved {len(resolved_refs)} reference image(s)")
# Emit event to notify frontend of parameter updates
try:
logger.info(

File diff suppressed because one or more lines are too long

View File

@@ -2552,21 +2552,23 @@ export type paths = {
* Args:
* queue_id: The queue ID to associate these parameters with
* parameters: The RecallParameter object containing the parameters to update
* strict: When true, parameters not included in the request body are reset
* to their defaults (cleared on the frontend). Defaults to false,
* which preserves the existing behaviour of only updating the
* parameters that are explicitly provided.
*
* Returns:
* A dictionary containing the updated parameters and status
*
* Example:
* POST /api/v1/recall/{queue_id}
* POST /api/v1/recall/{queue_id}?strict=true
* {
* "positive_prompt": "a beautiful landscape",
* "model": "sd-1.5",
* "steps": 20,
* "cfg_scale": 7.5,
* "width": 512,
* "height": 512,
* "seed": 12345
* "steps": 20
* }
* # In strict mode, all other parameters (reference_images, loras, etc.)
* # are cleared. In non-strict mode (default) they would be left as-is.
*/
post: operations["update_recall_parameters"];
delete?: never;
@@ -25175,6 +25177,11 @@ export type components = {
* @description List of IP Adapters with their settings
*/
ip_adapters?: components["schemas"]["IPAdapterRecallParameter"][] | null;
/**
* Reference Images
* @description List of model-free reference images for architectures that consume reference images directly (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit). The frontend picks the correct config type based on the currently-selected main model.
*/
reference_images?: components["schemas"]["ReferenceImageRecallParameter"][] | null;
};
/**
* RecallParametersUpdatedEvent
@@ -25274,6 +25281,24 @@ export type components = {
*/
type: "rectangle_mask";
};
/**
* ReferenceImageRecallParameter
* @description Global reference-image configuration for recall.
*
* Used for reference images that feed directly into the main model rather
* than through a separate IP-Adapter / ControlNet model for example
* FLUX.2 Klein, FLUX Kontext, and Qwen Image Edit. The receiving frontend
* picks the correct config type (``flux2_reference_image`` /
* ``qwen_image_reference_image`` / ``flux_kontext_reference_image``) based
* on the currently-selected main model.
*/
ReferenceImageRecallParameter: {
/**
* Image Name
* @description The filename of the reference image in outputs/images
*/
image_name: string;
};
/**
* RemoteModelFile
* @description Information about a downloadable file that forms part of a model.
@@ -36284,7 +36309,10 @@ export interface operations {
};
update_recall_parameters: {
parameters: {
query?: never;
query?: {
/** @description When true, parameters not included in the request are reset to their defaults (cleared). */
strict?: boolean;
};
header?: never;
path: {
/** @description The queue id to perform this operation on */

View File

@@ -4,6 +4,7 @@ import { socketConnected } from 'app/store/middleware/listenerMiddleware/listene
import type { AppStore } from 'app/store/store';
import { deepClone } from 'common/util/deepClone';
import { forEach, isNil, round } from 'es-toolkit/compat';
import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
import { allEntitiesDeleted, controlLayerRecalled } from 'features/controlLayers/store/canvasSlice';
import { canvasWorkflowIntegrationProcessingCompleted } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
import { loraAllDeleted, loraRecalled } from 'features/controlLayers/store/lorasSlice';
@@ -736,110 +737,182 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
}
}
// Handle IP Adapters as Reference Images
if (data.parameters.ip_adapters !== undefined && Array.isArray(data.parameters.ip_adapters)) {
log.debug(`Processing ${data.parameters.ip_adapters.length} IP adapter(s)`);
// Handle IP Adapters and model-free reference images together.
//
// Both ip_adapters and reference_images feed into the same refImages
// Redux slice. Previously they were dispatched as two independent
// Promise.all chains — the first with replace:true, the second with
// replace:false — which created a race: if a previous recall's
// reference-image promises were still in-flight they could resolve
// after the clear and re-append stale entries, doubling the list.
//
// Fix: collect every promise into a single array and dispatch exactly
// once with replace:true after all of them settle.
{
/* eslint-disable @typescript-eslint/no-explicit-any */
const ipAdaptersArr: any[] = Array.isArray(data.parameters.ip_adapters)
? (data.parameters.ip_adapters as any[])
: [];
const refImagesArr: any[] = Array.isArray(data.parameters.reference_images)
? (data.parameters.reference_images as any[])
: [];
/* eslint-enable @typescript-eslint/no-explicit-any */
// If the list is explicitly empty, clear existing reference images
if (data.parameters.ip_adapters.length === 0) {
dispatch(refImagesRecalled({ entities: [], replace: true }));
log.info('Cleared all IP adapter reference images');
} else {
// Build promises for all IP adapters, then dispatch once with replace: true
const ipAdapterPromises = data.parameters.ip_adapters
.filter((cfg) => cfg.model_key && typeof cfg.model_key === 'string')
.map(async (adapterConfig) => {
try {
const modelConfig = await dispatch(
modelsApi.endpoints.getModelConfig.initiate(adapterConfig.model_key!)
).unwrap();
const hasIpAdapters = data.parameters.ip_adapters !== undefined;
const hasRefImages = data.parameters.reference_images !== undefined;
// Pre-fetch the image DTO if an image is provided, to avoid validation errors
if (adapterConfig.image?.image_name) {
try {
await dispatch(imagesApi.endpoints.getImageDTO.initiate(adapterConfig.image.image_name)).unwrap();
} catch (imageError) {
log.warn(
`Could not pre-fetch image ${adapterConfig.image.image_name}, continuing anyway: ${imageError}`
if (hasIpAdapters || hasRefImages) {
const allRefImagePromises: Promise<RefImageState | null>[] = [];
// --- IP Adapters ---
if (hasIpAdapters && ipAdaptersArr.length > 0) {
log.debug(`Processing ${ipAdaptersArr.length} IP adapter(s)`);
const ipAdapterPromises = ipAdaptersArr
.filter((cfg: any) => cfg.model_key && typeof cfg.model_key === 'string') // eslint-disable-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(async (adapterConfig: any): Promise<RefImageState | null> => {
try {
const modelConfig = await dispatch(
modelsApi.endpoints.getModelConfig.initiate(adapterConfig.model_key!)
).unwrap();
// Pre-fetch the image DTO if an image is provided, to avoid validation errors
if (adapterConfig.image?.image_name) {
try {
await dispatch(imagesApi.endpoints.getImageDTO.initiate(adapterConfig.image.image_name)).unwrap();
} catch (imageError) {
log.warn(
`Could not pre-fetch image ${adapterConfig.image.image_name}, continuing anyway: ${imageError}`
);
}
}
// Build RefImageState using helper function - supports both ip_adapter and flux_redux
const imageData = adapterConfig.image
? {
original: {
image: {
image_name: adapterConfig.image.image_name,
width: adapterConfig.image.width ?? 512,
height: adapterConfig.image.height ?? 512,
},
},
}
: null;
const isFluxRedux = modelConfig.type === 'flux_redux';
const refImageState = getReferenceImageState(`recalled-ref-image-${Date.now()}-${Math.random()}`, {
isEnabled: true,
config: isFluxRedux
? {
type: 'flux_redux',
image: imageData,
model: {
key: modelConfig.key,
hash: modelConfig.hash,
name: modelConfig.name,
base: modelConfig.base,
type: modelConfig.type,
},
imageInfluence: (adapterConfig.image_influence as FLUXReduxImageInfluence) || 'highest',
}
: {
type: 'ip_adapter',
image: imageData,
model: {
key: modelConfig.key,
hash: modelConfig.hash,
name: modelConfig.name,
base: modelConfig.base,
type: modelConfig.type,
},
weight: typeof adapterConfig.weight === 'number' ? adapterConfig.weight : 1.0,
beginEndStepPct: [
typeof adapterConfig.begin_step_percent === 'number' ? adapterConfig.begin_step_percent : 0,
typeof adapterConfig.end_step_percent === 'number' ? adapterConfig.end_step_percent : 1,
] as [number, number],
method: (adapterConfig.method as IPMethodV2) || 'full',
clipVisionModel: 'ViT-H',
},
});
if (isFluxRedux) {
log.debug(`Built FLUX Redux ref image state: ${modelConfig.name}`);
} else {
log.debug(
`Built IP adapter ref image state: ${modelConfig.name} (weight: ${typeof adapterConfig.weight === 'number' ? adapterConfig.weight : 1.0})`
);
}
if (adapterConfig.image?.image_name) {
log.debug(
`IP adapter image: outputs/images/${adapterConfig.image.image_name} (${adapterConfig.image.width}x${adapterConfig.image.height})`
);
}
return refImageState;
} catch (error) {
log.error(`Failed to load IP adapter ${adapterConfig.model_key}: ${error}`);
return null;
}
});
allRefImagePromises.push(...ipAdapterPromises);
}
// --- Model-free reference images (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit) ---
// These feed the reference image directly into the main model rather than going
// through an IP Adapter, so the backend sends them without a model_key and we
// pick the right config type via getDefaultRefImageConfig() based on the main
// model that is currently selected in the UI.
if (hasRefImages && refImagesArr.length > 0) {
log.debug(`Processing ${refImagesArr.length} reference image(s)`);
const referenceImagePromises = refImagesArr
.filter((cfg: any) => cfg.image?.image_name && typeof cfg.image.image_name === 'string') // eslint-disable-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(async (refConfig: any): Promise<RefImageState | null> => {
const imageName = refConfig.image.image_name as string;
try {
// Pre-fetch the image DTO so ref image validation succeeds.
await dispatch(imagesApi.endpoints.getImageDTO.initiate(imageName)).unwrap();
} catch (imageError) {
log.warn(`Could not pre-fetch reference image ${imageName}, continuing anyway: ${imageError}`);
}
// Build RefImageState using helper function - supports both ip_adapter and flux_redux
const imageData = adapterConfig.image
? {
original: {
image: {
image_name: adapterConfig.image.image_name,
width: adapterConfig.image.width ?? 512,
height: adapterConfig.image.height ?? 512,
},
},
}
: null;
// Pick the config flavor (flux2 / flux_kontext / ip_adapter fallback) that
// matches the currently-selected main model.
const baseConfig = getDefaultRefImageConfig(getState);
const imageData = {
original: {
image: {
image_name: imageName,
width: typeof refConfig.image.width === 'number' ? refConfig.image.width : 512,
height: typeof refConfig.image.height === 'number' ? refConfig.image.height : 512,
},
},
};
const isFluxRedux = modelConfig.type === 'flux_redux';
const refImageState = getReferenceImageState(`recalled-ref-image-${Date.now()}-${Math.random()}`, {
return getReferenceImageState(`recalled-ref-image-${Date.now()}-${Math.random()}`, {
isEnabled: true,
config: isFluxRedux
? {
type: 'flux_redux',
image: imageData,
model: {
key: modelConfig.key,
hash: modelConfig.hash,
name: modelConfig.name,
base: modelConfig.base,
type: modelConfig.type,
},
imageInfluence: (adapterConfig.image_influence as FLUXReduxImageInfluence) || 'highest',
}
: {
type: 'ip_adapter',
image: imageData,
model: {
key: modelConfig.key,
hash: modelConfig.hash,
name: modelConfig.name,
base: modelConfig.base,
type: modelConfig.type,
},
weight: typeof adapterConfig.weight === 'number' ? adapterConfig.weight : 1.0,
beginEndStepPct: [
typeof adapterConfig.begin_step_percent === 'number' ? adapterConfig.begin_step_percent : 0,
typeof adapterConfig.end_step_percent === 'number' ? adapterConfig.end_step_percent : 1,
] as [number, number],
method: (adapterConfig.method as IPMethodV2) || 'full',
clipVisionModel: 'ViT-H',
},
config: { ...baseConfig, image: imageData },
});
});
if (isFluxRedux) {
log.debug(`Built FLUX Redux ref image state: ${modelConfig.name}`);
} else {
log.debug(
`Built IP adapter ref image state: ${modelConfig.name} (weight: ${typeof adapterConfig.weight === 'number' ? adapterConfig.weight : 1.0})`
);
}
if (adapterConfig.image?.image_name) {
log.debug(
`IP adapter image: outputs/images/${adapterConfig.image.image_name} (${adapterConfig.image.width}x${adapterConfig.image.height})`
);
}
allRefImagePromises.push(...referenceImagePromises);
}
return refImageState;
} catch (error) {
log.error(`Failed to load IP adapter ${adapterConfig.model_key}: ${error}`);
return null;
}
});
// Wait for all IP adapters to load, then dispatch with replace: true
Promise.all(ipAdapterPromises).then((refImageStates) => {
const validStates = refImageStates.filter((state): state is RefImageState => state !== null);
// Single dispatch after all IP adapter + reference image promises settle.
// Always replace:true so stale entries from a previous recall are cleared.
Promise.all(allRefImagePromises).then((results) => {
const validStates = results.filter((state): state is RefImageState => state !== null);
dispatch(refImagesRecalled({ entities: validStates, replace: true }));
if (validStates.length > 0) {
dispatch(refImagesRecalled({ entities: validStates, replace: true }));
log.info(`Applied ${validStates.length} IP adapter(s), replacing existing list`);
log.info(
`Applied ${validStates.length} reference image(s) (IP adapters + model-free), replacing existing list`
);
} else {
log.info('Cleared all reference images');
}
});
}

View File

@@ -0,0 +1,693 @@
"""Tests for the recall parameters router.
These tests monkey-patch the heavy-weight lookup helpers
(``resolve_model_name_to_key``, ``load_image_file``,
``process_controlnet_image``) rather than wiring up a real model manager
or image-files service. This keeps each test focused on the router's
request-validation, resolver sequencing, and broadcast payload shape.
"""
from collections.abc import Callable
from typing import Any, Optional
import pytest
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api.routers import recall_parameters as recall_module
from invokeai.app.api_app import app
from invokeai.app.services.invoker import Invoker
from invokeai.backend.model_manager.taxonomy import ModelType
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
class MockApiDependencies(ApiDependencies):
"""Minimal ApiDependencies stand-in that only wires up an invoker."""
invoker: Invoker
def __init__(self, invoker: Invoker) -> None:
self.invoker = invoker
@pytest.fixture
def patched_dependencies(monkeypatch: Any, mock_invoker: Invoker) -> MockApiDependencies:
"""Install a mock ApiDependencies for the recall_parameters router.
The router persists each parameter via ``client_state_persistence.set_by_key``,
whose ``user_id`` column has a FOREIGN KEY constraint back to the users
table. The mock invoker uses an in-memory SQLite database that is not
pre-populated with any users, so persistence would fail with "FOREIGN
KEY constraint failed" — that's an orthogonal concern to the reference-
images resolver under test, so we stub it out.
"""
dependencies = MockApiDependencies(mock_invoker)
monkeypatch.setattr("invokeai.app.api.routers.recall_parameters.ApiDependencies", dependencies)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", dependencies)
monkeypatch.setattr(
mock_invoker.services.client_state_persistence,
"set_by_key",
lambda user_id, key, value: value,
)
return dependencies
def make_name_to_key_stub(
mapping: dict[tuple[str, ModelType], str],
) -> Callable[[str, ModelType], Optional[str]]:
"""Build a ``resolve_model_name_to_key`` stand-in from a (name, type) dict.
Any lookup that is not present in ``mapping`` returns ``None``, mirroring
what the real resolver does when the model manager cannot find a match.
"""
def _lookup(model_name: str, model_type: ModelType = ModelType.Main) -> Optional[str]:
return mapping.get((model_name, model_type))
return _lookup
def make_load_image_file_stub(
known_images: dict[str, tuple[int, int]],
) -> Callable[[str], Optional[dict[str, Any]]]:
"""Build a ``load_image_file`` stand-in from a name → (width, height) dict."""
def _load(image_name: str) -> Optional[dict[str, Any]]:
dims = known_images.get(image_name)
if dims is None:
return None
width, height = dims
return {"image_name": image_name, "width": width, "height": height}
return _load
class TestReferenceImagesRecall:
def test_reference_images_forwarded_when_image_exists(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Reference images whose files exist should flow through to the event payload."""
# Stub load_image_file so we don't need a real outputs/images directory.
def fake_load_image_file(image_name: str) -> dict[str, Any] | None:
return {"image_name": image_name, "width": 1024, "height": 768}
monkeypatch.setattr(recall_module, "load_image_file", fake_load_image_file)
response = client.post(
"/api/v1/recall/default",
json={
"reference_images": [
{"image_name": "cat.png"},
{"image_name": "dog.png"},
]
},
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "success"
assert body["queue_id"] == "default"
# Both references came through, in order.
resolved = body["parameters"]["reference_images"]
assert len(resolved) == 2
assert resolved[0]["image"]["image_name"] == "cat.png"
assert resolved[1]["image"]["image_name"] == "dog.png"
assert resolved[0]["image"]["width"] == 1024
def test_missing_reference_images_are_dropped_without_failing(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""An image that can't be loaded should be skipped — never 500."""
def fake_load_image_file(image_name: str) -> dict[str, Any] | None:
if image_name == "present.png":
return {"image_name": image_name, "width": 512, "height": 512}
return None
monkeypatch.setattr(recall_module, "load_image_file", fake_load_image_file)
response = client.post(
"/api/v1/recall/default",
json={
"reference_images": [
{"image_name": "missing.png"},
{"image_name": "present.png"},
]
},
)
assert response.status_code == 200
resolved = response.json()["parameters"]["reference_images"]
assert len(resolved) == 1
assert resolved[0]["image"]["image_name"] == "present.png"
def test_reference_images_do_not_require_model_name(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""The schema must accept a reference image entry with only ``image_name``.
This pins down the "model-free" contract: unlike ``ip_adapters``,
these entries are for FLUX.2 Klein / FLUX Kontext / Qwen Image Edit,
where the reference image feeds the main model directly and there is
no adapter model to name. Callers should be able to omit every
field except ``image_name``.
"""
monkeypatch.setattr(
recall_module,
"load_image_file",
lambda image_name: {"image_name": image_name, "width": 64, "height": 64},
)
response = client.post(
"/api/v1/recall/default",
json={"reference_images": [{"image_name": "ok.png"}]},
)
assert response.status_code == 200
resolved = response.json()["parameters"]["reference_images"]
assert resolved == [{"image": {"image_name": "ok.png", "width": 64, "height": 64}}]
def test_empty_reference_images_is_noop_for_other_fields(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Sending an empty reference_images list should not break other fields."""
monkeypatch.setattr(
recall_module,
"load_image_file",
lambda image_name: {"image_name": image_name, "width": 1, "height": 1},
)
response = client.post(
"/api/v1/recall/default",
json={
"positive_prompt": "hello",
"reference_images": [],
},
)
assert response.status_code == 200
params = response.json()["parameters"]
assert params["positive_prompt"] == "hello"
assert params["reference_images"] == []
class TestLorasRecall:
def test_multiple_loras_resolved_with_weights_and_is_enabled(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Each LoRA's model name is resolved to a key and weight/is_enabled pass through."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub(
{
("detail-lora", ModelType.LoRA): "key-detail",
("style-lora", ModelType.LoRA): "key-style",
}
),
)
response = client.post(
"/api/v1/recall/default",
json={
"loras": [
{"model_name": "detail-lora", "weight": 0.8, "is_enabled": True},
{"model_name": "style-lora", "weight": 0.5, "is_enabled": False},
]
},
)
assert response.status_code == 200
loras = response.json()["parameters"]["loras"]
assert loras == [
{"model_key": "key-detail", "weight": 0.8, "is_enabled": True},
{"model_key": "key-style", "weight": 0.5, "is_enabled": False},
]
def test_unresolvable_loras_are_dropped(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""LoRAs whose names do not resolve are silently skipped — not an error."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("keeper", ModelType.LoRA): "key-keeper"}),
)
response = client.post(
"/api/v1/recall/default",
json={
"loras": [
{"model_name": "keeper", "weight": 0.7},
{"model_name": "ghost-lora"},
]
},
)
assert response.status_code == 200
loras = response.json()["parameters"]["loras"]
assert len(loras) == 1
assert loras[0]["model_key"] == "key-keeper"
def test_is_enabled_defaults_to_true(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Omitting is_enabled should default to True per the pydantic schema."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("x", ModelType.LoRA): "key-x"}),
)
response = client.post(
"/api/v1/recall/default",
json={"loras": [{"model_name": "x"}]},
)
assert response.status_code == 200
assert response.json()["parameters"]["loras"][0]["is_enabled"] is True
class TestControlLayersRecall:
def test_controlnet_resolution_takes_precedence(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""A name that matches a ControlNet model should resolve to it directly."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("canny", ModelType.ControlNet): "key-canny"}),
)
monkeypatch.setattr(
recall_module,
"load_image_file",
make_load_image_file_stub({"ctl.png": (512, 512)}),
)
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
response = client.post(
"/api/v1/recall/default",
json={
"control_layers": [
{
"model_name": "canny",
"image_name": "ctl.png",
"weight": 0.75,
"begin_step_percent": 0.1,
"end_step_percent": 0.9,
"control_mode": "balanced",
}
]
},
)
assert response.status_code == 200
layer = response.json()["parameters"]["control_layers"][0]
assert layer["model_key"] == "key-canny"
assert layer["weight"] == 0.75
assert layer["begin_step_percent"] == 0.1
assert layer["end_step_percent"] == 0.9
assert layer["control_mode"] == "balanced"
assert layer["image"] == {"image_name": "ctl.png", "width": 512, "height": 512}
# processor returned None → no processed_image field
assert "processed_image" not in layer
def test_falls_back_to_t2i_adapter(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""When no ControlNet match exists, T2I Adapter is tried next."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("sketchy", ModelType.T2IAdapter): "key-t2i"}),
)
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
response = client.post(
"/api/v1/recall/default",
json={"control_layers": [{"model_name": "sketchy", "weight": 1.0}]},
)
assert response.status_code == 200
assert response.json()["parameters"]["control_layers"][0]["model_key"] == "key-t2i"
def test_falls_back_to_control_lora(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""When neither ControlNet nor T2I Adapter matches, Control LoRA is tried last."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("clora", ModelType.LoRA): "key-clora"}),
)
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
response = client.post(
"/api/v1/recall/default",
json={"control_layers": [{"model_name": "clora", "weight": 1.0}]},
)
assert response.status_code == 200
assert response.json()["parameters"]["control_layers"][0]["model_key"] == "key-clora"
def test_missing_image_still_resolves_config(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""A missing control image is warned about but does not block the rest of the config."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("canny", ModelType.ControlNet): "key-canny"}),
)
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
response = client.post(
"/api/v1/recall/default",
json={
"control_layers": [
{
"model_name": "canny",
"image_name": "missing.png",
"weight": 0.75,
}
]
},
)
assert response.status_code == 200
layer = response.json()["parameters"]["control_layers"][0]
assert layer["model_key"] == "key-canny"
assert layer["weight"] == 0.75
assert "image" not in layer
assert "processed_image" not in layer
def test_processed_image_included_when_processor_returns_data(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""When the processor produces a derived image, it is attached to the resolved layer."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("canny", ModelType.ControlNet): "key-canny"}),
)
monkeypatch.setattr(
recall_module,
"load_image_file",
make_load_image_file_stub({"ctl.png": (768, 768)}),
)
monkeypatch.setattr(
recall_module,
"process_controlnet_image",
lambda image_name, model_key, services: {
"image_name": f"processed-{image_name}",
"width": 768,
"height": 768,
},
)
response = client.post(
"/api/v1/recall/default",
json={"control_layers": [{"model_name": "canny", "image_name": "ctl.png", "weight": 1.0}]},
)
assert response.status_code == 200
layer = response.json()["parameters"]["control_layers"][0]
assert layer["processed_image"]["image_name"] == "processed-ctl.png"
def test_unresolvable_control_layers_are_dropped(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Control entries whose model doesn't resolve by any type are skipped."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({}),
)
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
response = client.post(
"/api/v1/recall/default",
json={"control_layers": [{"model_name": "unknown", "weight": 1.0}]},
)
assert response.status_code == 200
assert response.json()["parameters"]["control_layers"] == []
class TestIPAdaptersRecall:
def test_ip_adapter_resolved_with_image_and_method(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""IPAdapter lookup is tried first and all config fields pass through."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("ipa-face", ModelType.IPAdapter): "key-ipa"}),
)
monkeypatch.setattr(
recall_module,
"load_image_file",
make_load_image_file_stub({"ref.png": (1024, 1024)}),
)
response = client.post(
"/api/v1/recall/default",
json={
"ip_adapters": [
{
"model_name": "ipa-face",
"image_name": "ref.png",
"weight": 0.7,
"begin_step_percent": 0.0,
"end_step_percent": 0.8,
"method": "style",
}
]
},
)
assert response.status_code == 200
adapter = response.json()["parameters"]["ip_adapters"][0]
assert adapter["model_key"] == "key-ipa"
assert adapter["weight"] == 0.7
assert adapter["begin_step_percent"] == 0.0
assert adapter["end_step_percent"] == 0.8
assert adapter["method"] == "style"
assert adapter["image"] == {"image_name": "ref.png", "width": 1024, "height": 1024}
# image_influence was not sent, so it must not appear in the resolved config
assert "image_influence" not in adapter
def test_falls_back_to_flux_redux(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""When the name doesn't match an IPAdapter, FluxRedux is tried next."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("redux-1", ModelType.FluxRedux): "key-redux"}),
)
monkeypatch.setattr(
recall_module,
"load_image_file",
make_load_image_file_stub({"ref.png": (512, 512)}),
)
response = client.post(
"/api/v1/recall/default",
json={
"ip_adapters": [
{
"model_name": "redux-1",
"image_name": "ref.png",
"weight": 1.0,
"image_influence": "high",
}
]
},
)
assert response.status_code == 200
adapter = response.json()["parameters"]["ip_adapters"][0]
assert adapter["model_key"] == "key-redux"
assert adapter["image_influence"] == "high"
def test_missing_image_still_resolves_config(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""A missing reference image is warned about but the adapter still lands."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("ipa", ModelType.IPAdapter): "key-ipa"}),
)
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
response = client.post(
"/api/v1/recall/default",
json={"ip_adapters": [{"model_name": "ipa", "image_name": "missing.png", "weight": 0.5}]},
)
assert response.status_code == 200
adapter = response.json()["parameters"]["ip_adapters"][0]
assert adapter["model_key"] == "key-ipa"
assert adapter["weight"] == 0.5
assert "image" not in adapter
def test_unresolvable_ip_adapters_are_dropped(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Adapters whose model can't be resolved (neither IPAdapter nor FluxRedux) are skipped."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({}),
)
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
response = client.post(
"/api/v1/recall/default",
json={"ip_adapters": [{"model_name": "unknown", "weight": 1.0}]},
)
assert response.status_code == 200
assert response.json()["parameters"]["ip_adapters"] == []
class TestCombinedRecall:
def test_all_collection_fields_together(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Exercise the full happy path: prompts, model, loras, control_layers, ip_adapters, reference_images."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub(
{
("my-model", ModelType.Main): "key-main",
("detail-lora", ModelType.LoRA): "key-lora",
("canny", ModelType.ControlNet): "key-canny",
("ipa-face", ModelType.IPAdapter): "key-ipa",
}
),
)
monkeypatch.setattr(
recall_module,
"load_image_file",
make_load_image_file_stub(
{
"ctl.png": (512, 512),
"face.png": (768, 768),
"ref.png": (1024, 1024),
}
),
)
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
response = client.post(
"/api/v1/recall/default",
json={
"positive_prompt": "a cat",
"negative_prompt": "blurry",
"model": "my-model",
"steps": 30,
"cfg_scale": 7.5,
"width": 512,
"height": 512,
"seed": 42,
"loras": [{"model_name": "detail-lora", "weight": 0.6}],
"control_layers": [{"model_name": "canny", "image_name": "ctl.png", "weight": 0.75}],
"ip_adapters": [
{"model_name": "ipa-face", "image_name": "face.png", "weight": 0.5, "method": "composition"}
],
"reference_images": [{"image_name": "ref.png"}],
},
)
assert response.status_code == 200
params = response.json()["parameters"]
# Core fields
assert params["positive_prompt"] == "a cat"
assert params["negative_prompt"] == "blurry"
assert params["model"] == "key-main"
assert params["steps"] == 30
assert params["seed"] == 42
# Collections
assert params["loras"] == [{"model_key": "key-lora", "weight": 0.6, "is_enabled": True}]
assert params["control_layers"][0]["model_key"] == "key-canny"
assert params["control_layers"][0]["image"]["image_name"] == "ctl.png"
assert params["ip_adapters"][0]["model_key"] == "key-ipa"
assert params["ip_adapters"][0]["method"] == "composition"
assert params["reference_images"] == [{"image": {"image_name": "ref.png", "width": 1024, "height": 1024}}]
def test_unresolvable_main_model_drops_from_payload(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""A model name that doesn't resolve should be scrubbed from the broadcast payload."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({}),
)
response = client.post(
"/api/v1/recall/default",
json={"positive_prompt": "x", "model": "ghost-model"},
)
assert response.status_code == 200
params = response.json()["parameters"]
assert params["positive_prompt"] == "x"
assert "model" not in params
class TestStrictMode:
"""Regression tests for the ``strict`` query parameter.
When ``strict=True``, parameters not included in the request body must
be reset — list-typed fields to ``[]`` and scalar fields to ``None``.
"""
def test_strict_clears_list_fields(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""List fields (loras, control_layers, ip_adapters, reference_images) are
sent as empty lists when omitted in strict mode."""
monkeypatch.setattr(recall_module, "resolve_model_name_to_key", make_name_to_key_stub({}))
response = client.post(
"/api/v1/recall/default?strict=true",
json={"positive_prompt": "hello"},
)
assert response.status_code == 200
params = response.json()["parameters"]
assert params["positive_prompt"] == "hello"
assert params["loras"] == []
assert params["control_layers"] == []
assert params["ip_adapters"] == []
assert params["reference_images"] == []
def test_strict_clears_scalar_fields(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Scalar fields not in the request are sent as None in strict mode."""
monkeypatch.setattr(recall_module, "resolve_model_name_to_key", make_name_to_key_stub({}))
response = client.post(
"/api/v1/recall/default?strict=true",
json={"steps": 20},
)
assert response.status_code == 200
params = response.json()["parameters"]
assert params["steps"] == 20
assert params["positive_prompt"] is None
assert params["seed"] is None
assert params["loras"] == []
def test_non_strict_omits_unset_fields(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Default (non-strict) behaviour: unset fields are absent from the response."""
monkeypatch.setattr(recall_module, "resolve_model_name_to_key", make_name_to_key_stub({}))
response = client.post(
"/api/v1/recall/default",
json={"positive_prompt": "hello"},
)
assert response.status_code == 200
params = response.json()["parameters"]
assert params["positive_prompt"] == "hello"
assert "loras" not in params
assert "reference_images" not in params
assert "seed" not in params