Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 55 additions & 6 deletions docs/src/content/docs/development/Guides/recall-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@ Content-Type: application/json

All parameters are optional — only send the fields you want to update.

#### Query parameters

The POST endpoint accepts two optional boolean query parameters that control
how reference images are merged into the frontend state:

| Parameter | Default | Description |
|-----------|---------|-------------|
| `strict` | `false` | When `true`, parameters **not** included in the request body are reset to their defaults (cleared on the frontend). When `false`, only the parameters you send are updated and everything else is left as-is. |
| `append` | `false` | When `true`, recalled reference images (`ip_adapters` and `reference_images`) are **appended** to the frontend's existing reference-image list instead of replacing it. When `false` (or omitted), the recalled reference images **replace** the existing list. |

`strict` and `append` are mutually exclusive — `strict` clears omitted
parameters while `append` preserves and extends the existing list, so the two
cannot be combined. Sending `?strict=true&append=true` returns
**400 Bad Request**:

```json
{
"detail": "The 'strict' and 'append' query parameters are mutually exclusive"
}
```

`append` only affects the reference-image collections (`ip_adapters` and
`reference_images`). All other parameters (prompts, model, LoRAs, control
layers, etc.) are updated the same way regardless of the flag.

### GET — Retrieve Recall Parameters

```http
Expand Down Expand Up @@ -294,6 +319,26 @@ curl -X POST http://localhost:9090/api/v1/recall/default \
}'
```

### Appending reference images (append mode)

By default, recalled reference images **replace** whatever the frontend
already has. Pass `?append=true` to **add** the recalled `ip_adapters` and
`reference_images` to the existing list instead:

```bash
# Add a reference image without clearing the ones already on the frontend
curl -X POST 'http://localhost:9090/api/v1/recall/default?append=true' \
-H "Content-Type: application/json" \
-d '{
"reference_images": [
{"image_name": "extra_reference.png"}
]
}'
```

Combining `append=true` with `strict=true` is invalid and returns
**400 Bad Request** (see [Query parameters](#query-parameters)).

### Model-free reference images (FLUX.2 Klein / FLUX Kontext / Qwen Image Edit)

```bash
Expand Down Expand Up @@ -426,13 +471,18 @@ 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.
4. Merge the recalled reference images — IP Adapter / FLUX Redux entries and
model-free reference images both feed the same reference-image list, using
the config flavor that matches the currently-selected main model. By
default this **replaces** the existing list; with `append=true` it is
**added** to whatever is already there (see
[Query parameters](#query-parameters)).

## Error Handling

- **400 Bad Request** — invalid parameters or parameter values.
- **400 Bad Request** — invalid parameters or parameter values, or the
mutually exclusive `strict=true&append=true` combination (see
[Query parameters](#query-parameters)).
- **500 Internal Server Error** — server-side storage or retrieval failure.

Errors include detailed messages. Missing images and unresolved model
Expand Down Expand Up @@ -460,8 +510,7 @@ 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)
INFO: Applied 2 reference image(s) (IP adapters + model-free), replacing existing list
DEBUG: Built IP adapter ref image state: ip-adapter-xyz... (weight: 0.7)
DEBUG: IP adapter image: outputs/images/depth_map.png (1024x768)
```
Expand Down
26 changes: 26 additions & 0 deletions invokeai/app/api/routers/recall_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,14 @@ async def update_recall_parameters(
default=False,
description="When true, parameters not included in the request are reset to their defaults (cleared).",
),
append: bool = Query(
default=False,
description=(
"When true, recalled reference images (ip_adapters and reference_images) are "
"appended to the frontend's existing reference-image list instead of replacing it. "
"Mutually exclusive with strict."
),
),
) -> dict[str, Any]:
"""
Update recallable parameters that can be recalled on the frontend.
Expand All @@ -421,6 +429,10 @@ async def update_recall_parameters(
to their defaults (cleared on the frontend). Defaults to false,
which preserves the existing behaviour of only updating the
parameters that are explicitly provided.
append: When true, recalled reference images (``ip_adapters`` and
``reference_images``) are appended to whatever reference images the
frontend already has, instead of replacing the whole list. Mutually
exclusive with ``strict`` (which clears omitted parameters).

Returns:
A dictionary containing the updated parameters and status
Expand All @@ -437,6 +449,12 @@ async def update_recall_parameters(
"""
logger = ApiDependencies.invoker.services.logger

if strict and append:
raise HTTPException(
status_code=400,
detail="The 'strict' and 'append' query parameters are mutually exclusive",
)

# Validate image access before processing — prevents information leakage
# (dimensions) and derived-image minting via ControlNet preprocessors.
_assert_recall_image_access(parameters, current_user)
Expand Down Expand Up @@ -522,6 +540,14 @@ async def update_recall_parameters(
provided_params["reference_images"] = resolved_refs
logger.info(f"Resolved {len(resolved_refs)} reference image(s)")

# Append mode rides along inside the event's parameters dict rather
# than as a new event field so the generated client schema (which
# types parameters as a free-form object) doesn't need regenerating.
# Added after the persistence loop above, so the flag itself is never
# stored as a recall parameter.
if append:
provided_params["append"] = True

# Emit event to notify frontend of parameter updates
try:
logger.info(
Expand Down
17 changes: 14 additions & 3 deletions invokeai/app/api/sockets.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,11 +313,22 @@ async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]):

logger.debug(f"Emitted private queue item event {event_name} to user room {user_room} and admin room")

# RecallParametersUpdatedEvent is private - only emit to owner + admins
# RecallParametersUpdatedEvent is private - only emit to owner + admins.
#
# Emit to the union of the owner room and the admin room in a SINGLE
# call. python-socketio deduplicates recipients across a room list,
# so a socket that belongs to BOTH rooms — e.g. the "system" user in
# single-user mode, which is also an admin — receives the event
# exactly once. Two separate emits would deliver it twice: harmless
# for the idempotent scalar recall fields (the frontend just re-sets
# them), but the append-mode reference-image recall *pushes* rather
# than replaces, so a double delivery adds the same reference image
# twice.
elif isinstance(event_data, RecallParametersUpdatedEvent):
user_room = f"user:{event_data.user_id}"
await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room=user_room)
await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin")
await self._sio.emit(
event=event_name, data=event_data.model_dump(mode="json"), room=[user_room, "admin"]
)
logger.debug(f"Emitted private recall_parameters_updated event to user room {user_room} and admin room")

# BatchEnqueuedEvent carries the enqueuing user's batch_id, origin, and
Expand Down
14 changes: 13 additions & 1 deletion invokeai/frontend/web/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9593,7 +9593,7 @@
"post": {
"tags": ["recall"],
"summary": "Update Recall Parameters",
"description": "Update recallable parameters that can be recalled on the frontend.\n\nThis endpoint allows updating parameters such as prompt, model, steps, and other\ngeneration settings. These parameters are stored in client state and can be\naccessed by the frontend to populate UI elements.\n\nArgs:\n queue_id: The queue ID to associate these parameters with\n parameters: The RecallParameter object containing the parameters to update\n strict: When true, parameters not included in the request body are reset\n to their defaults (cleared on the frontend). Defaults to false,\n which preserves the existing behaviour of only updating the\n parameters that are explicitly provided.\n\nReturns:\n A dictionary containing the updated parameters and status\n\nExample:\n POST /api/v1/recall/{queue_id}?strict=true\n {\n \"positive_prompt\": \"a beautiful landscape\",\n \"model\": \"sd-1.5\",\n \"steps\": 20\n }\n # In strict mode, all other parameters (reference_images, loras, etc.)\n # are cleared. In non-strict mode (default) they would be left as-is.",
"description": "Update recallable parameters that can be recalled on the frontend.\n\nThis endpoint allows updating parameters such as prompt, model, steps, and other\ngeneration settings. These parameters are stored in client state and can be\naccessed by the frontend to populate UI elements.\n\nArgs:\n queue_id: The queue ID to associate these parameters with\n parameters: The RecallParameter object containing the parameters to update\n strict: When true, parameters not included in the request body are reset\n to their defaults (cleared on the frontend). Defaults to false,\n which preserves the existing behaviour of only updating the\n parameters that are explicitly provided.\n append: When true, recalled reference images (``ip_adapters`` and\n ``reference_images``) are appended to whatever reference images the\n frontend already has, instead of replacing the whole list. Mutually\n exclusive with ``strict`` (which clears omitted parameters).\n\nReturns:\n A dictionary containing the updated parameters and status\n\nExample:\n POST /api/v1/recall/{queue_id}?strict=true\n {\n \"positive_prompt\": \"a beautiful landscape\",\n \"model\": \"sd-1.5\",\n \"steps\": 20\n }\n # In strict mode, all other parameters (reference_images, loras, etc.)\n # are cleared. In non-strict mode (default) they would be left as-is.",
"operationId": "update_recall_parameters",
"security": [
{
Expand Down Expand Up @@ -9623,6 +9623,18 @@
"title": "Strict"
},
"description": "When true, parameters not included in the request are reset to their defaults (cleared)."
},
{
"name": "append",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"description": "When true, recalled reference images (ip_adapters and reference_images) are appended to the frontend's existing reference-image list instead of replacing it. Mutually exclusive with strict.",
"default": false,
"title": "Append"
},
"description": "When true, recalled reference images (ip_adapters and reference_images) are appended to the frontend's existing reference-image list instead of replacing it. Mutually exclusive with strict."
}
],
"requestBody": {
Expand Down
6 changes: 6 additions & 0 deletions invokeai/frontend/web/src/services/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2684,6 +2684,10 @@ export type paths = {
* to their defaults (cleared on the frontend). Defaults to false,
* which preserves the existing behaviour of only updating the
* parameters that are explicitly provided.
* append: When true, recalled reference images (``ip_adapters`` and
* ``reference_images``) are appended to whatever reference images the
* frontend already has, instead of replacing the whole list. Mutually
* exclusive with ``strict`` (which clears omitted parameters).
*
* Returns:
* A dictionary containing the updated parameters and status
Expand Down Expand Up @@ -38452,6 +38456,8 @@ export interface operations {
query?: {
/** @description When true, parameters not included in the request are reset to their defaults (cleared). */
strict?: boolean;
/** @description When true, recalled reference images (ip_adapters and reference_images) are appended to the frontend's existing reference-image list instead of replacing it. Mutually exclusive with strict. */
append?: boolean;
};
header?: never;
path: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,10 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis

const hasIpAdapters = data.parameters.ip_adapters !== undefined;
const hasRefImages = data.parameters.reference_images !== undefined;
// Append mode (POST /api/v1/recall/{queue_id}?append=true): add the
// recalled reference images to the existing list instead of replacing
// it. The backend passes the flag inside the parameters dict.
const append = data.parameters.append === true;

if (hasIpAdapters || hasRefImages) {
const allRefImagePromises: Promise<RefImageState | null>[] = [];
Expand Down Expand Up @@ -874,9 +878,21 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
}

// Single dispatch after all IP adapter + reference image promises settle.
// Always replace:true so stale entries from a previous recall are cleared.
// replace:true (the default) clears stale entries from a previous
// recall; append mode instead pushes onto the existing list and
// deliberately dispatches nothing when no valid states resolved, so
// a failed append can never wipe the user's current reference images.
Promise.all(allRefImagePromises).then((results) => {
const validStates = results.filter((state): state is RefImageState => state !== null);
if (append) {
if (validStates.length > 0) {
dispatch(refImagesRecalled({ entities: validStates, replace: false }));
log.info(
`Appended ${validStates.length} reference image(s) (IP adapters + model-free) to existing list`
);
}
return;
}
dispatch(refImagesRecalled({ entities: validStates, replace: true }));
if (validStates.length > 0) {
log.info(
Expand Down
38 changes: 38 additions & 0 deletions tests/app/routers/test_multiuser_authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -1844,6 +1844,44 @@ def test_queue_cleared_still_broadcast(self, socketio: Any) -> None:
rooms_emitted_to = [call.kwargs.get("room") for call in mock_emit.call_args_list]
assert "default" in rooms_emitted_to

def test_recall_parameters_emitted_once_to_owner_and_admin_rooms(self, socketio: Any) -> None:
"""RecallParametersUpdatedEvent must be delivered to the owner + admin rooms
in a SINGLE emit call (room list), not two separate emits.

A socket that is in both rooms — e.g. the system user in single-user mode,
who is also an admin — would otherwise receive the event twice. That is
harmless for the idempotent scalar recall fields but doubles every entry
for the append-mode reference-image recall, which pushes rather than
replaces. python-socketio deduplicates recipients across a room list, so
a single emit to [user_room, "admin"] delivers exactly once per socket.
"""
import asyncio
from unittest.mock import AsyncMock

from invokeai.app.services.events.events_common import RecallParametersUpdatedEvent

event = RecallParametersUpdatedEvent.build(
queue_id="default",
user_id="owner-recall",
parameters={"reference_images": [{"image": {"image_name": "cat.png"}}], "append": True},
)

mock_emit = AsyncMock()
socketio._sio.emit = mock_emit

asyncio.run(socketio._handle_queue_event(("recall_parameters_updated", event)))

# Exactly one emit, targeting the union of the owner and admin rooms.
assert mock_emit.call_count == 1, (
"recall event must be emitted once to a room list, not once per room — "
"two emits double-deliver to a socket in both rooms"
)
room = mock_emit.call_args.kwargs.get("room")
assert isinstance(room, list)
assert set(room) == {"user:owner-recall", "admin"}
# And never to the shared queue room, which would leak to other users.
assert "default" not in room


class TestCustomNodesAuthorization:
"""Tests that custom_nodes endpoints enforce AdminUserOrDefault.
Expand Down
Loading
Loading