From 41638bf5087679e79e792946c534e9fe753a76e9 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 21:53:41 -0600 Subject: [PATCH 1/4] feat(generation): restore hosted-provider options, canvas modes, and record editing Add OpenAI/Gemini/Seedream options and provider named sizes to the Generate panel, compile them into the provider node and metadata, and recall them from images. Let external models generate from the Canvas in the modes they advertise (txt2img, img2img, inpaint) and reject outpaint with a named reason. Add an External Provider editor to the model detail for provider ids, modes, support flags, limits, and defaults; extend the mock backend with external fixtures and model PATCH. --- .../Users Guide/03.Models/01.introduction.mdx | 8 + .../03.Models/External Models/gemini.mdx | 9 + .../03.Models/External Models/openai.mdx | 11 +- .../03.Models/External Models/seedream.mdx | 8 +- .../frontend/webv2/public/locales/en.json | 71 ++++- .../webv2/scripts/mock-backend-fixtures.mjs | 118 +++++++- .../frontend/webv2/scripts/mock-backend.mjs | 13 +- .../core/baseGenerationPolicies.test.ts | 21 ++ .../generation/core/baseGenerationPolicies.ts | 45 ++- .../core/canvas/compileCanvasGraph.test.ts | 97 +++++- .../core/canvas/compileCanvasGraph.ts | 72 +++-- .../generation/core/externalProvider.test.ts | 91 ++++++ .../generation/core/externalProvider.ts | 188 ++++++++++++ .../features/generation/core/graph.test.ts | 114 +++++++ .../src/features/generation/core/graph.ts | 40 ++- .../features/generation/core/settings.test.ts | 50 ++++ .../src/features/generation/core/settings.ts | 22 ++ .../src/features/generation/core/types.ts | 55 +++- .../webv2/src/features/generation/settings.ts | 10 + .../generation/ui/GenerateDimensionFields.tsx | 13 + ...ateProviderOptionsSection.browser.test.tsx | 182 ++++++++++++ .../ui/GenerateProviderOptionsSection.tsx | 260 ++++++++++++++++ .../generation/ui/GenerateSettingsForm.tsx | 3 + .../ExternalSizeSelect.browser.test.tsx | 148 +++++++++ .../ui/shared/ExternalSizeSelect.tsx | 101 +++++++ .../webv2/src/features/models/core/schemas.ts | 46 +++ .../webv2/src/features/models/core/types.ts | 43 ++- .../ExternalModelSection.browser.test.tsx | 187 ++++++++++++ .../models/ui/detail/ExternalModelSection.tsx | 280 ++++++++++++++++++ .../features/models/ui/detail/ModelDetail.tsx | 43 +++ .../ui/detail/externalModelFields.test.ts | 120 ++++++++ .../models/ui/detail/externalModelFields.ts | 83 ++++++ .../models/ui/modelDraftsStore.test.ts | 15 + .../features/models/ui/modelDraftsStore.ts | 12 +- .../importGalleryImages.test.ts | 8 + .../image-actions/imageRecall.test.ts | 148 ++++++++- .../workbench/image-actions/imageRecall.ts | 111 ++++++- .../src/workbench/workbenchState.test.ts | 8 + 38 files changed, 2787 insertions(+), 67 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/generation/core/externalProvider.test.ts create mode 100644 invokeai/frontend/webv2/src/features/generation/core/externalProvider.ts create mode 100644 invokeai/frontend/webv2/src/features/generation/ui/GenerateProviderOptionsSection.browser.test.tsx create mode 100644 invokeai/frontend/webv2/src/features/generation/ui/GenerateProviderOptionsSection.tsx create mode 100644 invokeai/frontend/webv2/src/features/generation/ui/shared/ExternalSizeSelect.browser.test.tsx create mode 100644 invokeai/frontend/webv2/src/features/generation/ui/shared/ExternalSizeSelect.tsx create mode 100644 invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.browser.test.tsx create mode 100644 invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.tsx create mode 100644 invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.test.ts create mode 100644 invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.ts diff --git a/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx b/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx index 71708b62470..a61e2869594 100644 --- a/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx @@ -98,6 +98,14 @@ information on how to generate these keys. Once a valid API key has been provided, the list of models hosted by that service will appear as selections in the Image Generation panel. +Opening an external model in the Model Manager shows an **External +Provider** section where you can change the provider and provider model +ID it routes to, the modes (text to image, image to image, inpaint) and +inputs (seed, reference images, negative prompt) it advertises, its +request limits, and the size and image count the provider is asked for +by default. The Generate panel and the Canvas only offer what the +record advertises, so keep it in step with the provider's documentation. + :::note[Gated Downloads] The API Keys section is also used to provide keys for services that require authentication to download certain models, such as those diff --git a/docs/src/content/docs/Users Guide/03.Models/External Models/gemini.mdx b/docs/src/content/docs/Users Guide/03.Models/External Models/gemini.mdx index 53067376488..1573a60a242 100644 --- a/docs/src/content/docs/Users Guide/03.Models/External Models/gemini.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/External Models/gemini.mdx @@ -39,6 +39,15 @@ Reference-image input is used to condition generation but counts as txt2img — All Gemini models are single-image-per-request — batch size is fixed at 1. To generate multiple variations, queue multiple invocations. +## Provider-Specific Options + +Selecting a Gemini model adds a **Provider options** section to the Generate panel: + +- **Temperature** — Override the sampling temperature (0–2; the provider default is 1). +- **Thinking level** — `minimal` or `high`, or the provider default. + +Gemini 3 models list their resolution presets under **Provider size** in the Size section. Picking one sets the width and height to the preset and sends its named size (`1K`, `2K`, `4K`) to the API. The named size and the sampling options are recorded in the image metadata and restored by **Recall All** and **Remix**. + ## Tips diff --git a/docs/src/content/docs/Users Guide/03.Models/External Models/openai.mdx b/docs/src/content/docs/Users Guide/03.Models/External Models/openai.mdx index 2ee4628ebd3..adbb53c771c 100644 --- a/docs/src/content/docs/Users Guide/03.Models/External Models/openai.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/External Models/openai.mdx @@ -44,12 +44,19 @@ Inpainting (mask-based editing) is not currently supported for any OpenAI model ## Provider-Specific Options -For **GPT Image** models, Invoke surfaces two provider-specific options in the parameters panel: +Selecting an OpenAI model adds a **Provider options** section to the Generate panel: - **Quality** — `low`, `medium`, `high`, or `auto`. Higher quality costs more and takes longer. - **Background** — `auto`, `transparent`, or `opaque`. Use `transparent` for PNG output with an alpha channel. +- **Input fidelity** — `low` or `high`, offered only for models that take image inputs (img2img or reference images). Leave it on the provider default to let OpenAI choose. -DALL·E 2 and DALL·E 3 do not expose these options. +These are recorded in the image metadata, so **Recall All** and **Remix** restore them when the image's model is selected. + +The **Size** section lists the provider's fixed sizes per aspect ratio (**Provider size**). Any other size is mapped to the closest one the provider accepts. + +## Canvas + +GPT Image models that advertise `img2img` can generate from the Canvas: an empty bounding box runs text-to-image, and a filled one sends the composited bounding box as the init image. Inpainting and outpainting are not available for OpenAI models. ## How Requests Are Routed diff --git a/docs/src/content/docs/Users Guide/03.Models/External Models/seedream.mdx b/docs/src/content/docs/Users Guide/03.Models/External Models/seedream.mdx index de25273d289..efd57170120 100644 --- a/docs/src/content/docs/Users Guide/03.Models/External Models/seedream.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/External Models/seedream.mdx @@ -48,11 +48,17 @@ All Seedream models share the same aspect ratio set: `1:1`, `2:3`, `3:2`, `3:4`, ## Provider-Specific Options -Seedream exposes two provider-specific toggles in the parameters panel: +Selecting a Seedream model adds a **Provider options** section to the Generate panel with two toggles: - **Watermark** — When enabled, BytePlus adds a small watermark to the output. Off by default. - **Optimize Prompt** — When enabled, BytePlus rewrites your prompt server-side for better generation quality. Useful for short prompts; disable if you want the exact wording preserved. +Both are recorded in the image metadata and restored by **Recall All** and **Remix**. The Size section lists the provider's 2K sizes per aspect ratio under **Provider size**. + +## Canvas + +Seedream models advertise `img2img`, so they can generate from the Canvas: an empty bounding box runs text-to-image, and a filled one sends the composited bounding box as the init image. Inpainting and outpainting are not available. + **Seed** and **guidance scale** are not accepted by the 4.x / 5.x family. ## Reference Images diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 728a9fad1f5..45c2750e3a5 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -1335,7 +1335,36 @@ "emptyCacheConfirmBody": "Unloads all models from RAM and VRAM. The next generation reloads them from disk.", "emptyCacheConfirmTitle": "Empty model cache?", "providerId": "Provider", + "providerIdHelp": "The provider whose API key and endpoint handle this model (e.g. openai, gemini, seedream).", "providerModelId": "Provider Model ID", + "providerModelIdHelp": "The model name the provider expects in requests.", + "externalProvider": "External Provider", + "externalProviderHelp": "Where requests for this model go, what the provider accepts, and the size and count it is asked for by default.", + "saveExternalSettings": "Save Provider Settings", + "externalSettingsSaved": "Provider settings saved", + "failedToSaveExternalSettings": "Failed to save provider settings.", + "invalidExternalSettings": "Invalid provider settings.", + "externalModes": { + "txt2img": "Text to image", + "img2img": "Image to image", + "inpaint": "Inpaint" + }, + "externalFields": { + "supportedModes": "Supported modes", + "supportedModesHelp": "Image to image and inpaint let the canvas send its composite to the provider.", + "supportsSeed": "Accepts a seed", + "supportsReferenceImages": "Accepts reference images", + "supportsNegativePrompt": "Accepts a negative prompt", + "maxImagesPerRequest": "Images per request", + "maxReferenceImages": "Max reference images", + "maxImageWidth": "Max width", + "maxImageHeight": "Max height", + "defaultWidth": "Default width", + "defaultHeight": "Default height", + "defaultNumImages": "Default image count", + "unlimited": "No limit", + "providerDefault": "Provider default" + }, "queueAttentionCount_one": "{{count}} needs attention", "queueAttentionCount_other": "{{count}} need attention", "queueActionFailed": "Queue action failed", @@ -2655,7 +2684,47 @@ "pidSteps": "PiD steps", "pidHelp": "Replaces the VAE decode with a 4x super-resolution decode.", "pidFitHelp": "Generates at the requested size, then decodes 4x and scales back down.", - "pidNativeHelp": "The requested size is the 4x target; generation runs at a quarter of it." + "pidNativeHelp": "The requested size is the 4x target; generation runs at a quarter of it.", + "providerSize": "Provider size", + "providerSizeHelp": "Sizes this provider renders natively. Other sizes are mapped to the closest one.", + "providerSizeCustom": "Custom", + "providerOptions": { + "title": "Provider options", + "providerDefault": "Provider default", + "openaiQuality": "Quality", + "openaiQualityHelp": "Higher quality costs more and takes longer.", + "openaiQualityOptions": { + "auto": "Auto", + "high": "High", + "medium": "Medium", + "low": "Low" + }, + "openaiBackground": "Background", + "openaiBackgroundHelp": "Transparent keeps an alpha channel in the PNG output.", + "openaiBackgroundOptions": { + "auto": "Auto", + "transparent": "Transparent", + "opaque": "Opaque" + }, + "openaiInputFidelity": "Input fidelity", + "openaiInputFidelityHelp": "How closely edits and reference images are preserved.", + "openaiInputFidelityOptions": { + "low": "Low", + "high": "High" + }, + "geminiTemperature": "Temperature", + "geminiTemperatureHelp": "Sampling temperature from 0 to 2; the provider default is 1.", + "geminiThinkingLevel": "Thinking level", + "geminiThinkingLevelHelp": "How much the model reasons about the prompt before rendering.", + "geminiThinkingLevelOptions": { + "minimal": "Minimal", + "high": "High" + }, + "seedreamWatermark": "Watermark", + "seedreamWatermarkHelp": "Adds the provider's small watermark to the output.", + "seedreamOptimizePrompt": "Optimize prompt", + "seedreamOptimizePromptHelp": "Lets the provider rewrite the prompt before generating. Turn off to keep the exact wording." + } }, "upscale": { "concepts": "Concepts", diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs index b29a99707e6..800e354d78d 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs @@ -396,7 +396,123 @@ const createQueueItems = (count) => const MODEL_BASES = ['sd-1', 'sdxl', 'flux', 'flux2', 'qwen-image']; const MODEL_TYPES = ['main', 'lora', 'controlnet', 'vae', 'embedding']; -const createModels = (count) => +/** + * Hosted-provider records with the capability shapes the Generate panel, Canvas, and Model Manager read: a named + * resolution table (Gemini), fixed sizes per ratio with an edit mode (OpenAI), and provider toggles (Seedream). + */ +const EXTERNAL_MODEL_FIXTURES = [ + { + base: 'external', + capabilities: { + allowed_aspect_ratios: ['1:1', '16:9', '9:16'], + max_image_size: { height: 4096, width: 4096 }, + max_images_per_request: 1, + max_reference_images: 14, + modes: ['txt2img'], + resolution_presets: ['1K', '2K'].flatMap((imageSize) => + [ + ['1:1', 1, 1], + ['16:9', 16, 9], + ['9:16', 9, 16], + ].map(([ratio, w, h]) => { + const base = imageSize === '1K' ? 1024 : 2048; + const width = w >= h ? base : Math.round((base * w) / h); + const height = w >= h ? Math.round((base * h) / w) : base; + return { + aspect_ratio: ratio, + height, + image_size: imageSize, + label: `${ratio} (${imageSize}) — ${width}×${height}`, + width, + }; + }) + ), + supports_reference_images: true, + supports_seed: true, + }, + cover_image: null, + default_settings: { height: 1024, num_images: 1, width: 1024 }, + description: 'Fixture hosted model with named resolution presets and sampling options.', + file_size: 0, + format: 'external_api', + hash: 'external:gemini:fixture-gemini-image', + key: 'fixture-external-gemini', + name: 'Fixture Gemini Image', + path: 'external://gemini/fixture-gemini-image', + provider_id: 'gemini', + provider_model_id: 'fixture-gemini-image', + source: 'external://gemini/fixture-gemini-image', + source_type: 'external', + source_url: null, + trigger_phrases: null, + type: 'external_image_generator', + }, + { + base: 'external', + capabilities: { + allowed_aspect_ratios: ['1:1', '3:2', '2:3'], + aspect_ratio_sizes: { + '1:1': { height: 1024, width: 1024 }, + '3:2': { height: 1024, width: 1536 }, + '2:3': { height: 1536, width: 1024 }, + }, + max_images_per_request: 10, + modes: ['txt2img', 'img2img', 'inpaint'], + supports_reference_images: true, + }, + cover_image: null, + default_settings: { height: 1024, num_images: 1, width: 1024 }, + description: 'Fixture hosted model with fixed ratio sizes and canvas edit modes.', + file_size: 0, + format: 'external_api', + hash: 'external:openai:fixture-gpt-image', + key: 'fixture-external-openai', + name: 'Fixture GPT Image', + path: 'external://openai/fixture-gpt-image', + provider_id: 'openai', + provider_model_id: 'fixture-gpt-image', + source: 'external://openai/fixture-gpt-image', + source_type: 'external', + source_url: null, + trigger_phrases: null, + type: 'external_image_generator', + }, + { + base: 'external', + capabilities: { + allowed_aspect_ratios: ['1:1', '16:9'], + aspect_ratio_sizes: { '1:1': { height: 2048, width: 2048 }, '16:9': { height: 1600, width: 2848 } }, + max_images_per_request: 15, + max_reference_images: 14, + modes: ['txt2img', 'img2img'], + supports_reference_images: true, + }, + cover_image: null, + default_settings: { height: 2048, num_images: 1, width: 2048 }, + description: 'Fixture hosted model with provider toggles.', + file_size: 0, + format: 'external_api', + hash: 'external:seedream:fixture-seedream', + key: 'fixture-external-seedream', + name: 'Fixture Seedream', + path: 'external://seedream/fixture-seedream', + provider_id: 'seedream', + provider_model_id: 'fixture-seedream', + source: 'external://seedream/fixture-seedream', + source_type: 'external', + source_url: null, + trigger_phrases: null, + type: 'external_image_generator', + }, +]; + +/** The last slots of the model library hold the hosted-provider records so the profile counts stay as declared. */ +const createModels = (count) => [ + ...createLocalModels(Math.max(0, count - EXTERNAL_MODEL_FIXTURES.length)), + ...EXTERNAL_MODEL_FIXTURES.slice(0, count).map((model) => structuredClone(model)), +]; + +const createLocalModels = (count) => range(count, (index) => { const id = ordinal(index); const type = MODEL_TYPES[index % MODEL_TYPES.length]; diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 74836005032..89ca19c97c2 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -1242,7 +1242,18 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const key = decodeURIComponent(modelMatch[1]); const model = state.models.get(key); - return model ? json(200, model) : json(404, { detail: 'Model not found' }); + if (!model) { + return json(404, { detail: 'Model not found' }); + } + if (method === 'PATCH') { + // Mirrors ModelRecordService.update_model: each submitted field replaces the stored one wholesale. + const changes = await readJsonBody(request); + const updated = { ...model, ...changes }; + state.models.set(key, updated); + return json(200, updated); + } + + return json(200, model); } if (method === 'GET' && (path === '/api/v2/models' || path === '/api/v2/models/')) { diff --git a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts index 68d069e8e72..0cf3dbb37a7 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts @@ -575,6 +575,27 @@ describe('component policies', () => { ); }); + it('validates the external mode being compiled against the modes the model advertises', () => { + const model: GenerateModelConfig = { + ...externalModel, + capabilities: { modes: ['txt2img', 'inpaint'] }, + }; + + expect(getGenerationValidationReasons(model, createSettings(model))).toEqual([]); + expect(getGenerationValidationReasons(model, createSettings(model), 'inpaint')).toEqual([]); + expect(getGenerationValidationReasons(model, createSettings(model), 'img2img')).toContain( + 'External Model does not support image-to-image generation.' + ); + // Modes default to txt2img only when the record omits them. + expect( + getGenerationValidationReasons({ ...externalModel, capabilities: null }, createSettings(externalModel), 'inpaint') + ).toContain('External Model does not support inpainting.'); + }); + + it('accepts provider sizes off the local model grids for external generators', () => { + expect(getGenerationValidationReasons(externalModel, createSettings(externalModel, { width: 1365 }))).toEqual([]); + }); + it('renders no component slots for bases without extra requirements', () => { expect(getComponentSectionPolicy(createModel('cogview4'), createSettings(createModel('cogview4'))).slots).toEqual( [] diff --git a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts index e3e849062f2..5dcf4cd2f33 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts @@ -18,6 +18,7 @@ import { import { SEED_MAX } from '@platform/core/seed'; import type { + ExternalGenerationMode, GenerateModelConfig, GenerateReferenceImage, GenerateReferenceImageAsset, @@ -192,11 +193,12 @@ const getBaseGenerationConfig = ( return getArchitectureGenerationConfig(model.base, model.variant) ?? FALLBACK_GENERATION_CONFIG; }; -/** Use variant-aware grids; return null for undescribed local architectures. External providers use a generic grid. */ +/** Pixel-exact: hosted providers bucket requests to their own size tables, which are not grid-aligned. */ +const EXTERNAL_DIMENSION_GRID = 1; + +/** Use variant-aware grids; return null for undescribed local architectures. External providers have no grid. */ export const getDimensionGrid = (base: string, variant?: unknown): number | null => - base === 'external' - ? FALLBACK_GENERATION_CONFIG.dimensions.grid - : (getArchitectureFeatures(base, variant)?.dimension_grid ?? null); + base === 'external' ? EXTERNAL_DIMENSION_GRID : (getArchitectureFeatures(base, variant)?.dimension_grid ?? null); /** A served architecture row does not imply frontend graph support. */ export const isArchitectureDescribed = (model: Pick): boolean => @@ -219,7 +221,7 @@ export const getGenerationDimensions = ( const { grid, optimalSide } = getPidDimensionOverrides( pidMode, model?.base, - config.dimensions.grid, + model?.type === 'external_image_generator' ? EXTERNAL_DIMENSION_GRID : config.dimensions.grid, config.dimensions.optimalSide ); @@ -477,6 +479,14 @@ export const getDefaultGenerateSettings = (model?: GenerateModelConfig): Generat pidDecoderModel: null, gemma2EncoderModel: null, pidSteps: DEFAULT_PID_STEPS, + externalImageSize: null, + openaiQuality: 'auto', + openaiBackground: 'auto', + openaiInputFidelity: null, + geminiTemperature: null, + geminiThinkingLevel: null, + seedreamWatermark: false, + seedreamOptimizePrompt: false, referenceImages: [], scheduler: defaults.scheduler, seamlessXAxis: false, @@ -1669,7 +1679,24 @@ const getModelFamilyValidationReasons = (model: MainModelConfig, settings: Gener return reasons; }; -export const getGenerationValidationReasons = (model: GenerateModelConfig, settings: GenerateSettings): string[] => { +const EXTERNAL_MODE_LABELS: Record = { + img2img: 'image-to-image generation', + inpaint: 'inpainting', + txt2img: 'text-to-image generation', +}; + +/** Why an external model cannot run `mode`, or null when it advertises it. */ +export const getExternalModeReason = (model: GenerateModelConfig, mode: ExternalGenerationMode): string | null => + model.type === 'external_image_generator' && !(model.capabilities?.modes ?? ['txt2img']).includes(mode) + ? `${model.name} does not support ${EXTERNAL_MODE_LABELS[mode]}.` + : null; + +/** `mode` is the external generation mode being compiled; local models are validated the same way for every mode. */ +export const getGenerationValidationReasons = ( + model: GenerateModelConfig, + settings: GenerateSettings, + mode: ExternalGenerationMode = 'txt2img' +): string[] => { // All compilers share this validator; check capabilities before model-specific policy. if (!hasArchitectureCapabilities()) { // Keep core independent of the load-state store; failures direct callers to retry. @@ -1694,8 +1721,10 @@ export const getGenerationValidationReasons = (model: GenerateModelConfig, setti ]; if (model.type === 'external_image_generator') { - if (model.capabilities?.modes && !model.capabilities.modes.includes('txt2img')) { - reasons.push(`${model.name} does not support text-to-image generation.`); + const modeReason = getExternalModeReason(model, mode); + + if (modeReason) { + reasons.push(modeReason); } if (!getExternalProviderNodeType(model.provider_id)) { diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts index f0c5d3d695a..80610f788cd 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts @@ -406,15 +406,96 @@ describe('compileCanvasGraph', () => { ); }); - describe('unsupported models', () => { - it('rejects external image generators for txt2img', () => { - expect(() => compile(externalModel, 'txt2img')).toThrow('does not support canvas generation'); + describe('external image generators', () => { + const editModel: GenerateModelConfig = { + ...externalModel, + capabilities: { modes: ['txt2img', 'img2img', 'inpaint'], supports_seed: true }, + }; + const smallBbox: Rect = { height: 512, width: 640, x: 10, y: 20 }; + + it('generates txt2img from an empty bbox on one provider node sized to the bbox, with no encode or resize', () => { + const { backendGraph, mode } = compile(externalModel, 'txt2img', { + bbox: smallBbox, + compositeImageName: null, + scaling: { height: null, method: 'auto', width: null }, + }); + const output = backendGraph.nodes.canvas_output; + + expect(mode).toBe('txt2img'); + expect(output).toMatchObject({ + height: 512, + is_intermediate: true, + mode: 'txt2img', + type: 'openai_image_generation', + width: 640, + }); + expect(output).not.toHaveProperty('init_image'); + expect(output).not.toHaveProperty('mask_image'); + expect(getEdge(backendGraph, 'canvas_output', 'prompt')?.source.node_id).toBe('positive_prompt'); + expect(getEdge(backendGraph, 'canvas_output', 'seed')?.source.node_id).toBe('seed'); + expect(backendGraph.nodes.canvas_i2l).toBeUndefined(); + expect(backendGraph.nodes.canvas_processing_output).toBeUndefined(); + expect(Object.values(backendGraph.nodes).map((node) => node.type)).not.toContain('img_resize'); + // The provider returns one image at the bbox size, so metadata records the bbox too. + expect(getNodeByType(backendGraph, 'core_metadata')).toMatchObject({ height: 512, width: 640 }); }); - it('rejects external image generators for img2img', () => { - expect(() => compile(externalModel, 'img2img')).toThrow('does not support canvas generation'); + it('sends the composited bbox as the init image for img2img when the model advertises it', () => { + const { backendGraph } = compile(editModel, 'img2img', { compositeImageName: 'bbox.png' }); + + expect(backendGraph.nodes.canvas_output).toMatchObject({ + init_image: { image_name: 'bbox.png' }, + mode: 'img2img', + }); + expect(backendGraph.nodes.canvas_output).not.toHaveProperty('mask_image'); + // No local denoise path is grafted: the provider edits the whole image. + expect(backendGraph.nodes.denoise_latents).toBeUndefined(); + expect(backendGraph.nodes.create_gradient_mask).toBeUndefined(); + }); + + it('sends the composite and the grayscale mask for inpaint', () => { + const { backendGraph } = compile(editModel, 'inpaint', { + compositeImageName: 'bbox.png', + maskImageName: 'mask.png', + }); + + expect(backendGraph.nodes.canvas_output).toMatchObject({ + init_image: { image_name: 'bbox.png' }, + mask_image: { image_name: 'mask.png' }, + mode: 'inpaint', + }); + // The provider composites its own edit, so no blend/apply-mask tail follows it. + expect(backendGraph.nodes.invokeai_img_blend).toBeUndefined(); + expect(backendGraph.nodes.expand_mask).toBeUndefined(); }); + it('still requires the composite and the mask the mode needs', () => { + expect(() => compile(editModel, 'img2img', { compositeImageName: null })).toThrow( + 'Canvas generation requires a composited source image.' + ); + expect(() => compile(editModel, 'inpaint', { maskImageName: null })).toThrow( + 'Canvas inpainting requires an inpaint mask.' + ); + }); + + it('rejects the edit modes a model does not advertise, naming the mode', () => { + expect(() => compile(externalModel, 'img2img')).toThrow( + 'OpenAI Image does not support image-to-image generation.' + ); + expect(() => compile(externalModel, 'inpaint')).toThrow('OpenAI Image does not support inpainting.'); + expect(() => + compile({ ...externalModel, capabilities: { modes: ['img2img'] } }, 'txt2img', { compositeImageName: null }) + ).toThrow('OpenAI Image does not support text-to-image generation.'); + }); + + it('rejects outpaint even for a model that advertises every provider mode', () => { + expect(() => compile(editModel, 'outpaint', { maskImageName: null })).toThrow( + 'OpenAI Image does not support outpainting on the canvas.' + ); + }); + }); + + describe('unsupported models', () => { it('rejects PiD with an actionable message rather than an internal VAE error', () => { // PiD lacks the VAE seam expected by canvas compilation, so reject it explicitly. for (const mode of ['txt2img', 'img2img'] as const) { @@ -844,12 +925,6 @@ describe('compileCanvasGraph — outpaint per base', () => { expect(backendGraph.nodes.create_gradient_mask?.minimum_denoise).toBe(0.3); expect(backendGraph.nodes.expand_mask?.fade_size_px).toBe(24); }); - - it('rejects an external model for every image mode', () => { - expect(() => compile(externalModel, 'outpaint', { maskImageName: null })).toThrow( - 'does not support canvas generation' - ); - }); }); // Exercise control grafting through the real compiler entry point. diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts index 8155895bd1e..3ef293b735e 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts @@ -1,6 +1,6 @@ /** - * Pure canvas compiler over uploaded inputs. Resize through the processing grid and restore the bbox; reject - * external models and unsupported encode modes. + * Pure canvas compiler over uploaded inputs. Resize through the processing grid and restore the bbox; hosted + * providers take the bbox composite as-is in the modes they advertise; reject unsupported encode modes. */ import type { SupportedGenerateBase } from '@features/generation/core/baseGenerationPolicies'; @@ -8,7 +8,8 @@ import type { BackendGraphContract, BackendInvocationContract } from '@features/ import type { GenerateModelConfig, GenerateSettings } from '@features/generation/core/types'; import { getGenerationValidationReasons } from '@features/generation/core/baseGenerationPolicies'; -import { GRAPH_BUILDERS } from '@features/generation/core/graph'; +import { isExternalGenerationMode } from '@features/generation/core/externalProvider'; +import { buildExternalGraph, GRAPH_BUILDERS } from '@features/generation/core/graph'; import { addEdge, addNode, toGraphContract } from '@features/generation/core/graphBuilder'; import { addKrea2ConditioningEnhancers } from '@features/generation/core/krea2Conditioning'; import { getIsPidSupportedBase } from '@features/generation/core/pid'; @@ -62,9 +63,10 @@ const getCanvasValidationReasons = (input: CompileCanvasGraphInput): string[] => const { bbox, compositeImageName, maskImageName, mode, model, strength } = input; const reasons: string[] = []; - if (model.type === 'external_image_generator') { - reasons.push(`${model.name} does not support canvas generation.`); - return reasons; + // Providers take a whole image to edit; a partially covered bbox has nothing to send for the empty area. The + // shared validator reports the other modes the model does not advertise. + if (model.type === 'external_image_generator' && mode === 'outpaint') { + reasons.push(`${model.name} does not support outpainting on the canvas.`); } // Reject canvas PiD because its graph lacks the expected VAE/decode seam. @@ -232,9 +234,6 @@ const addInputResizeNode = ( /** Resolves the encode node type for a supported base (throws otherwise). */ const requireI2lType = (model: GenerateModelConfig): string => { - if (model.type === 'external_image_generator') { - throw new Error(`${model.name} does not support canvas generation.`); - } const i2lType = CANVAS_I2L_NODE_TYPES[model.base as SupportedGenerateBase]; if (!i2lType) { throw new Error(`Canvas generation is not supported for ${model.name}.`); @@ -611,27 +610,66 @@ const graftOutpaint = ( /** Throw the first validation reason. */ export const compileCanvasGraph = (input: CompileCanvasGraphInput): CompiledCanvasGraph => { const { bbox, compositeImageName, destination, mode, model, projectSettings, strength } = input; - const processingSize = resolveCanvasProcessingSize(model, input.settings.pidMode, bbox, input.scaling); + // A provider buckets the request to its own size table and returns one image, so it is asked for the bbox itself. + const processingSize = + model.type === 'external_image_generator' + ? { height: bbox.height, width: bbox.width } + : resolveCanvasProcessingSize(model, input.settings.pidMode, bbox, input.scaling); const settings = withProcessingDimensions(input.settings, processingSize); - const validationReasons = [...getCanvasValidationReasons(input), ...getGenerationValidationReasons(model, settings)]; + const validationReasons = [ + ...getCanvasValidationReasons(input), + ...getGenerationValidationReasons(model, settings, isExternalGenerationMode(mode) ? mode : 'txt2img'), + ]; if (validationReasons.length > 0) { throw new Error(validationReasons[0]); } - const builder = GRAPH_BUILDERS[model.base as SupportedGenerateBase]; - - if (!builder || model.type === 'external_image_generator') { - throw new Error(`${model.name} does not support canvas generation.`); - } - // Canvas outputs are intermediate; gallery outputs are durable. const outputIsIntermediate = destination === 'canvas'; const runtimeProjectSettings = { ...projectSettings, randDevice: input.randDevice ?? (projectSettings.useCpuNoise ? 'cpu' : 'cuda'), }; + + if (model.type === 'external_image_generator') { + if (!isExternalGenerationMode(mode)) { + throw new Error(`${model.name} does not support outpainting on the canvas.`); + } + + // Validation above guarantees the composite for the edit modes and the mask for inpaint. The provider returns + // the edited image whole, so no encode, mask tail, control, or regional grafts apply. + const initImageName = compositeImageName as string; + const backendGraph = buildExternalGraph( + settings, + model, + outputIsIntermediate, + runtimeProjectSettings, + mode === 'txt2img' + ? { mode } + : mode === 'img2img' + ? { initImageName, mode } + : { initImageName, maskImageName: input.maskImageName as string, mode } + ); + setCanvasMetadataDimensions(backendGraph, bbox); + + return { + backendGraph, + graph: toGraphContract(backendGraph, `${model.name} ${mode}`), + mode, + negativePromptNodeId: 'negative_prompt', + positivePromptNodeId: 'positive_prompt', + seedNodeId: 'seed', + }; + } + + const builder = GRAPH_BUILDERS[model.base as SupportedGenerateBase]; + + if (!builder) { + throw new Error(`Canvas generation is not supported for ${model.name}.`); + } + const backendGraph = builder(settings, model, outputIsIntermediate, runtimeProjectSettings); const { compositing } = input; diff --git a/invokeai/frontend/webv2/src/features/generation/core/externalProvider.test.ts b/invokeai/frontend/webv2/src/features/generation/core/externalProvider.test.ts new file mode 100644 index 00000000000..e23290caf76 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/externalProvider.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import type { ExternalImageGeneratorModelConfig, GenerateModelConfig } from './types'; + +import { + findExternalResolutionPreset, + getExternalAspectRatioSizes, + getExternalProviderOptionKeys, + resolveExternalImageSize, +} from './externalProvider'; + +const external = ( + providerId: string, + capabilities: ExternalImageGeneratorModelConfig['capabilities'] +): GenerateModelConfig => ({ + base: 'external', + capabilities, + format: 'external_api', + key: `${providerId}-model`, + name: providerId, + provider_id: providerId, + type: 'external_image_generator', +}); + +const presets = [ + { aspect_ratio: '1:1', height: 1024, image_size: '1K', label: '1:1 (1K)', width: 1024 }, + { aspect_ratio: '16:9', height: 1152, image_size: '2K', label: '16:9 (2K)', width: 2048 }, +]; + +describe('external provider options', () => { + it('offers each provider only the options its node declares', () => { + expect(getExternalProviderOptionKeys(external('gemini', { modes: ['txt2img'] }))).toEqual([ + 'geminiTemperature', + 'geminiThinkingLevel', + ]); + expect(getExternalProviderOptionKeys(external('seedream', { modes: ['txt2img'] }))).toEqual([ + 'seedreamWatermark', + 'seedreamOptimizePrompt', + ]); + // Alibaba Cloud and Atlas Cloud nodes add nothing beyond the shared fields. + expect(getExternalProviderOptionKeys(external('alibabacloud', { modes: ['txt2img'] }))).toEqual([]); + expect(getExternalProviderOptionKeys({ base: 'sdxl', key: 'sdxl', name: 'SDXL', type: 'main' })).toEqual([]); + expect(getExternalProviderOptionKeys(undefined)).toEqual([]); + }); + + it('offers OpenAI input fidelity only to models that take image inputs', () => { + expect(getExternalProviderOptionKeys(external('openai', { modes: ['txt2img'] }))).toEqual([ + 'openaiQuality', + 'openaiBackground', + ]); + expect(getExternalProviderOptionKeys(external('openai', { modes: ['txt2img', 'img2img'] }))).toContain( + 'openaiInputFidelity' + ); + expect( + getExternalProviderOptionKeys(external('openai', { modes: ['txt2img'], supports_reference_images: true })) + ).toContain('openaiInputFidelity'); + }); + + it('resolves a named size only from the selected model’s own presets', () => { + const gemini = external('gemini', { modes: ['txt2img'], resolution_presets: presets }); + + expect(resolveExternalImageSize({ externalImageSize: '2K' }, gemini)).toBe('2K'); + expect(resolveExternalImageSize({ externalImageSize: '4K' }, gemini)).toBeNull(); + expect( + resolveExternalImageSize({ externalImageSize: '2K' }, external('openai', { modes: ['txt2img'] })) + ).toBeNull(); + }); + + it('matches the preset select to the exact size and named size, so a hand-typed size reads as custom', () => { + const gemini = external('gemini', { modes: ['txt2img'], resolution_presets: presets }); + + expect(findExternalResolutionPreset({ externalImageSize: '2K', height: 1152, width: 2048 }, gemini)?.label).toBe( + '16:9 (2K)' + ); + expect(findExternalResolutionPreset({ externalImageSize: '2K', height: 1152, width: 2000 }, gemini)).toBeNull(); + expect(findExternalResolutionPreset({ externalImageSize: null, height: 1024, width: 1024 }, gemini)).toBeNull(); + }); + + it('lists fixed ratio sizes in the provider’s order', () => { + const openai = external('openai', { + aspect_ratio_sizes: { '1:1': { height: 1024, width: 1024 }, '3:2': { height: 1024, width: 1536 } }, + modes: ['txt2img'], + }); + + expect(getExternalAspectRatioSizes(openai)).toEqual([ + ['1:1', { height: 1024, width: 1024 }], + ['3:2', { height: 1024, width: 1536 }], + ]); + expect(getExternalAspectRatioSizes(external('gemini', { modes: ['txt2img'] }))).toEqual([]); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/core/externalProvider.ts b/invokeai/frontend/webv2/src/features/generation/core/externalProvider.ts new file mode 100644 index 00000000000..84f81fce724 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/externalProvider.ts @@ -0,0 +1,188 @@ +/** + * Pure policy for hosted image providers: which provider-specific options a selected external model exposes, the + * named sizes and fixed-size ratios it advertises, and the invocation fields those settings compile to. + */ + +import type { + ExternalGenerationMode, + ExternalImageSize, + ExternalModelCapabilities, + ExternalResolutionPreset, + GeminiThinkingLevel, + GenerateModelConfig, + GenerateSettings, + OpenAIImageBackground, + OpenAIImageQuality, + OpenAIInputFidelity, +} from './types'; + +export const OPENAI_IMAGE_QUALITIES: readonly OpenAIImageQuality[] = ['auto', 'high', 'medium', 'low']; +export const OPENAI_IMAGE_BACKGROUNDS: readonly OpenAIImageBackground[] = ['auto', 'transparent', 'opaque']; +export const OPENAI_INPUT_FIDELITIES: readonly OpenAIInputFidelity[] = ['low', 'high']; +export const GEMINI_THINKING_LEVELS: readonly GeminiThinkingLevel[] = ['minimal', 'high']; +/** `GeminiImageGenerationInvocation.temperature` is `ge=0, le=2`; the provider default is 1. */ +export const GEMINI_TEMPERATURE_MIN = 0; +export const GEMINI_TEMPERATURE_MAX = 2; +export const DEFAULT_GEMINI_TEMPERATURE = 1; + +export const isOpenAIImageQuality = (value: unknown): value is OpenAIImageQuality => + OPENAI_IMAGE_QUALITIES.includes(value as OpenAIImageQuality); +export const isOpenAIImageBackground = (value: unknown): value is OpenAIImageBackground => + OPENAI_IMAGE_BACKGROUNDS.includes(value as OpenAIImageBackground); +export const isOpenAIInputFidelity = (value: unknown): value is OpenAIInputFidelity => + OPENAI_INPUT_FIDELITIES.includes(value as OpenAIInputFidelity); +export const isGeminiThinkingLevel = (value: unknown): value is GeminiThinkingLevel => + GEMINI_THINKING_LEVELS.includes(value as GeminiThinkingLevel); +export const isExternalGenerationMode = (value: unknown): value is ExternalGenerationMode => + value === 'txt2img' || value === 'img2img' || value === 'inpaint'; + +export type ExternalProviderOptionKey = keyof Pick< + GenerateSettings, + | 'openaiQuality' + | 'openaiBackground' + | 'openaiInputFidelity' + | 'geminiTemperature' + | 'geminiThinkingLevel' + | 'seedreamWatermark' + | 'seedreamOptimizePrompt' +>; + +const getCapabilities = (model: GenerateModelConfig | undefined): ExternalModelCapabilities | null => + model?.type === 'external_image_generator' ? (model.capabilities ?? null) : null; + +const getProviderId = (model: GenerateModelConfig | undefined): string | null => + model?.type === 'external_image_generator' && typeof model.provider_id === 'string' ? model.provider_id : null; + +/** True when the model accepts image inputs in any form (an edit mode or reference images). */ +const acceptsImageInputs = (capabilities: ExternalModelCapabilities | null): boolean => + capabilities?.supports_reference_images === true || + (capabilities?.modes ?? []).some((mode) => mode === 'img2img' || mode === 'inpaint'); + +/** + * The provider options the selected model's invocation accepts, in display order. Each provider's node declares its + * own fields, so the provider decides; OpenAI's input fidelity is only consulted for image inputs, so a model that + * takes none does not offer it. + */ +export const getExternalProviderOptionKeys = (model: GenerateModelConfig | undefined): ExternalProviderOptionKey[] => { + const capabilities = getCapabilities(model); + + switch (getProviderId(model)) { + case 'openai': + return [ + 'openaiQuality', + 'openaiBackground', + ...(acceptsImageInputs(capabilities) ? (['openaiInputFidelity'] as const) : []), + ]; + case 'gemini': + return ['geminiTemperature', 'geminiThinkingLevel']; + case 'seedream': + return ['seedreamWatermark', 'seedreamOptimizePrompt']; + default: + return []; + } +}; + +export const getExternalResolutionPresets = (model: GenerateModelConfig | undefined): ExternalResolutionPreset[] => { + const presets = getCapabilities(model)?.resolution_presets; + + return Array.isArray(presets) ? presets : []; +}; + +/** Fixed sizes per ratio, as `[ratio, size]` pairs in the provider's order; empty for free-form providers. */ +export const getExternalAspectRatioSizes = ( + model: GenerateModelConfig | undefined +): Array<[ratio: string, size: ExternalImageSize]> => { + const sizes = getCapabilities(model)?.aspect_ratio_sizes; + + return sizes ? Object.entries(sizes) : []; +}; + +/** The named size to send: only one the model lists, so a stale pick from another model never reaches the provider. */ +export const resolveExternalImageSize = ( + settings: Pick, + model: GenerateModelConfig | undefined +): string | null => + settings.externalImageSize !== null && + getExternalResolutionPresets(model).some((preset) => preset.image_size === settings.externalImageSize) + ? settings.externalImageSize + : null; + +/** The preset matching the current size and named size exactly, for the preset select's value. */ +export const findExternalResolutionPreset = ( + settings: Pick, + model: GenerateModelConfig | undefined +): ExternalResolutionPreset | null => + getExternalResolutionPresets(model).find( + (preset) => + preset.image_size === settings.externalImageSize && + preset.width === settings.width && + preset.height === settings.height + ) ?? null; + +/** + * Provider-specific invocation fields for the selected model, keyed as the node declares them. Optional fields are + * omitted when unset so the node applies its own default. + */ +export const getExternalProviderInvocationOptions = ( + settings: GenerateSettings, + model: GenerateModelConfig +): Record => { + const keys = new Set(getExternalProviderOptionKeys(model)); + const options: Record = {}; + + if (keys.has('openaiQuality')) { + options.quality = settings.openaiQuality; + } + if (keys.has('openaiBackground')) { + options.background = settings.openaiBackground; + } + if (keys.has('openaiInputFidelity') && settings.openaiInputFidelity !== null) { + options.input_fidelity = settings.openaiInputFidelity; + } + if (keys.has('geminiTemperature') && settings.geminiTemperature !== null) { + options.temperature = settings.geminiTemperature; + } + if (keys.has('geminiThinkingLevel') && settings.geminiThinkingLevel !== null) { + options.thinking_level = settings.geminiThinkingLevel; + } + if (keys.has('seedreamWatermark')) { + options.watermark = settings.seedreamWatermark; + } + if (keys.has('seedreamOptimizePrompt')) { + options.optimize_prompt = settings.seedreamOptimizePrompt; + } + + return options; +}; + +/** Metadata keys mirror what the provider nodes record themselves, so Generate and Canvas outputs recall alike. */ +export const EXTERNAL_METADATA_KEYS: Record = { + geminiTemperature: 'gemini_temperature', + geminiThinkingLevel: 'gemini_thinking_level', + openaiBackground: 'openai_background', + openaiInputFidelity: 'openai_input_fidelity', + openaiQuality: 'openai_quality', + seedreamOptimizePrompt: 'seedream_optimize_prompt', + seedreamWatermark: 'seedream_watermark', +}; + +export const getExternalProviderMetadata = ( + settings: GenerateSettings, + model: GenerateModelConfig +): Record => { + const metadata: Record = {}; + const imageSize = resolveExternalImageSize(settings, model); + + if (imageSize !== null) { + metadata.image_size = imageSize; + } + for (const key of getExternalProviderOptionKeys(model)) { + const value = settings[key]; + + if (value !== null) { + metadata[EXTERNAL_METADATA_KEYS[key]] = value; + } + } + + return metadata; +}; diff --git a/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts b/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts index 789d05819c4..04e1853ba11 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ComponentModelConfig, + ExternalImageGeneratorModelConfig, GenerateModelConfig, GenerateSettings, LoraModelConfig, @@ -600,6 +601,119 @@ describe('compileGenerateGraph', () => { expect(graph.nodes.canvas_output?.reference_images).toEqual([{ image_name: 'reference.png' }]); }); + describe('external provider options', () => { + const providerSettings: Partial = { + externalImageSize: '2K', + geminiTemperature: 0.4, + geminiThinkingLevel: 'high', + openaiBackground: 'transparent', + openaiInputFidelity: 'high', + openaiQuality: 'low', + seedreamOptimizePrompt: true, + seedreamWatermark: true, + }; + const providerModel = ( + providerId: string, + capabilities: ExternalImageGeneratorModelConfig['capabilities'] + ): GenerateModelConfig => ({ ...externalModel, capabilities, provider_id: providerId }) as GenerateModelConfig; + + it('compiles OpenAI quality, background and input fidelity, recording them in metadata', () => { + const model = providerModel('openai', { modes: ['txt2img', 'img2img'] }); + const graph = compile(model, providerSettings); + + expect(graph.nodes.canvas_output).toMatchObject({ + background: 'transparent', + input_fidelity: 'high', + quality: 'low', + }); + // Fields of other providers' nodes never reach this one. + expect(graph.nodes.canvas_output).not.toHaveProperty('temperature'); + expect(graph.nodes.canvas_output).not.toHaveProperty('watermark'); + expect(getNodeByType(graph, 'core_metadata')).toMatchObject({ + openai_background: 'transparent', + openai_input_fidelity: 'high', + openai_quality: 'low', + }); + }); + + it('omits OpenAI input fidelity for a model with no image inputs, and when unset', () => { + const noInputs = compile(providerModel('openai', { modes: ['txt2img'] }), providerSettings); + + expect(noInputs.nodes.canvas_output).toMatchObject({ background: 'transparent', quality: 'low' }); + expect(noInputs.nodes.canvas_output).not.toHaveProperty('input_fidelity'); + + const unset = compile(providerModel('openai', { modes: ['txt2img', 'img2img'] }), { + ...providerSettings, + openaiInputFidelity: null, + }); + + expect(unset.nodes.canvas_output).not.toHaveProperty('input_fidelity'); + expect(getNodeByType(unset, 'core_metadata')).not.toHaveProperty('openai_input_fidelity'); + }); + + it('compiles Gemini temperature and thinking level only when set', () => { + const model = providerModel('gemini', { modes: ['txt2img'] }); + const graph = compile(model, providerSettings); + + expect(graph.nodes.canvas_output).toMatchObject({ temperature: 0.4, thinking_level: 'high' }); + expect(graph.nodes.canvas_output).not.toHaveProperty('quality'); + expect(getNodeByType(graph, 'core_metadata')).toMatchObject({ + gemini_temperature: 0.4, + gemini_thinking_level: 'high', + }); + + const defaults = compile(model, { ...providerSettings, geminiTemperature: null, geminiThinkingLevel: null }); + + expect(defaults.nodes.canvas_output).not.toHaveProperty('temperature'); + expect(defaults.nodes.canvas_output).not.toHaveProperty('thinking_level'); + }); + + it('compiles Seedream watermark and prompt optimization as explicit booleans', () => { + const model = providerModel('seedream', { modes: ['txt2img'] }); + + expect(compile(model, providerSettings).nodes.canvas_output).toMatchObject({ + optimize_prompt: true, + watermark: true, + }); + expect( + compile(model, { ...providerSettings, seedreamOptimizePrompt: false, seedreamWatermark: false }).nodes + .canvas_output + ).toMatchObject({ optimize_prompt: false, watermark: false }); + expect(getNodeByType(compile(model, providerSettings), 'core_metadata')).toMatchObject({ + seedream_optimize_prompt: true, + seedream_watermark: true, + }); + }); + + it('sends the named size only when the model lists it in a resolution preset', () => { + const presets = [ + { aspect_ratio: '1:1', height: 2048, image_size: '2K', label: '1:1 (2K)', width: 2048 }, + { aspect_ratio: '1:1', height: 1024, image_size: '1K', label: '1:1 (1K)', width: 1024 }, + ]; + const listed = compile(providerModel('gemini', { modes: ['txt2img'], resolution_presets: presets }), { + ...providerSettings, + height: 2048, + width: 2048, + }); + + expect(listed.nodes.canvas_output).toMatchObject({ height: 2048, image_size: '2K', width: 2048 }); + expect(getNodeByType(listed, 'core_metadata')).toMatchObject({ image_size: '2K' }); + + // A size picked for another model, or a provider without named sizes, falls back to the provider default. + const unlisted = compile(providerModel('gemini', { modes: ['txt2img'] }), providerSettings); + + expect(unlisted.nodes.canvas_output?.image_size).toBeNull(); + expect(getNodeByType(unlisted, 'core_metadata')).not.toHaveProperty('image_size'); + }); + + it('accepts provider sizes that are not multiples of a local model grid', () => { + const model = providerModel('gemini', { modes: ['txt2img'] }); + const graph = compile(model, { height: 2048, width: 1365 }); + + expect(graph.nodes.canvas_output).toMatchObject({ height: 2048, width: 1365 }); + }); + }); + it('wires SD IP Adapter reference images into denoise', () => { const graph = compile(sd1Model, { referenceImages: [ diff --git a/invokeai/frontend/webv2/src/features/generation/core/graph.ts b/invokeai/frontend/webv2/src/features/generation/core/graph.ts index 02be3aae118..e11ea63c51f 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graph.ts @@ -21,6 +21,7 @@ import type { import { coerceSchedulerForGraph, + getExternalModeReason, getExternalProviderNodeType, getFlux2DiffusersComponentSource, getGenerationDimensions, @@ -42,6 +43,11 @@ import { isVaeCompatibleWithGenerateModel, type GenerateComponentFilter, } from './componentCompatibility'; +import { + getExternalProviderInvocationOptions, + getExternalProviderMetadata, + resolveExternalImageSize, +} from './externalProvider'; import { addEdge, addMetadata, @@ -1430,18 +1436,32 @@ const buildWanGraph = ( return graph; }; -const buildExternalGraph = ( +/** Canvas inputs per external mode: the composited bbox for the edit modes and its grayscale mask for inpaint. */ +export type ExternalCanvasInputs = + | { mode: 'txt2img' } + | { mode: 'img2img'; initImageName: string } + | { mode: 'inpaint'; initImageName: string; maskImageName: string }; + +/** + * Hosted providers take the whole request on one node: prompt, seed, size, provider options, and (on the canvas) the + * init image and mask. The provider composites its own edits, so the node's image is the canvas output as-is. + */ +export const buildExternalGraph = ( settings: GenerateSettings, model: GenerateModelConfig, outputIsIntermediate: boolean, - projectSettings: GenerationProjectSettings + projectSettings: GenerationProjectSettings, + canvas: ExternalCanvasInputs | null = null ): BackendGraphContract => { if (model.type !== 'external_image_generator') { throw new Error('Selected model is not an external image generator.'); } - if (model.capabilities?.modes && !model.capabilities.modes.includes('txt2img')) { - throw new Error(`${model.name} does not support text-to-image generation.`); + const mode = canvas?.mode ?? 'txt2img'; + const modeReason = getExternalModeReason(model, mode); + + if (modeReason) { + throw new Error(modeReason); } const providerId = typeof model.provider_id === 'string' ? model.provider_id : ''; @@ -1456,14 +1476,17 @@ const buildExternalGraph = ( const output = addNode(graph, { height: settings.height, id: 'canvas_output', - image_size: null, + image_size: resolveExternalImageSize(settings, model), is_intermediate: outputIsIntermediate, - mode: 'txt2img', + mode, model, num_images: 1, type: nodeType, use_cache: false, width: settings.width, + ...getExternalProviderInvocationOptions(settings, model), + ...(canvas && canvas.mode !== 'txt2img' ? { init_image: { image_name: canvas.initImageName } } : {}), + ...(canvas?.mode === 'inpaint' ? { mask_image: { image_name: canvas.maskImageName } } : {}), }); addEdge(graph, positivePrompt, 'value', output, 'prompt'); @@ -1484,7 +1507,10 @@ const buildExternalGraph = ( (output as Record).reference_images = referenceImages; } } - addMetadata(graph, output, settings, model, null, projectSettings, { scheduler: undefined }); + addMetadata(graph, output, settings, model, null, projectSettings, { + scheduler: undefined, + ...getExternalProviderMetadata(settings, model), + }); addReferenceImageMetadata(graph, output, settings); return graph; diff --git a/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts b/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts index bd621bc83ae..ceb0fea3975 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts @@ -279,6 +279,54 @@ describe('normalizeGenerateSettings', () => { expect(normalized?.vaePrecision).toBe('fp32'); }); + it('keeps valid hosted-provider options and drops the rest back to provider defaults', () => { + const kept = normalizeGenerateSettings({ + ...legacyStoredValues, + externalImageSize: '2K', + geminiTemperature: 0.5, + geminiThinkingLevel: 'minimal', + openaiBackground: 'opaque', + openaiInputFidelity: 'high', + openaiQuality: 'medium', + seedreamOptimizePrompt: true, + seedreamWatermark: true, + }); + + expect(kept).toMatchObject({ + externalImageSize: '2K', + geminiTemperature: 0.5, + geminiThinkingLevel: 'minimal', + openaiBackground: 'opaque', + openaiInputFidelity: 'high', + openaiQuality: 'medium', + seedreamOptimizePrompt: true, + seedreamWatermark: true, + }); + + const dropped = normalizeGenerateSettings({ + ...legacyStoredValues, + externalImageSize: '', + geminiTemperature: 2.5, + geminiThinkingLevel: 'max', + openaiBackground: 'clear', + openaiInputFidelity: 'medium', + openaiQuality: 'ultra', + seedreamOptimizePrompt: 'yes', + seedreamWatermark: 1, + }); + + expect(dropped).toMatchObject({ + externalImageSize: null, + geminiTemperature: null, + geminiThinkingLevel: null, + openaiBackground: 'auto', + openaiInputFidelity: null, + openaiQuality: 'auto', + seedreamOptimizePrompt: false, + seedreamWatermark: false, + }); + }); + it('drops malformed persisted model identifiers missing a base', () => { const normalized = normalizeGenerateSettings({ ...legacyStoredValues, @@ -508,6 +556,8 @@ describe('dimension helpers', () => { expect(getGenerationDimensions({ base: 'flux2', type: 'main' }).grid).toBe(16); expect(getGenerationDimensions({ base: 'qwen-image', type: 'main' }).grid).toBe(16); expect(getGenerationDimensions({ base: 'cogview4', type: 'main' }).grid).toBe(32); + // Hosted providers bucket sizes themselves, so their presets (e.g. 1365 wide) are kept pixel-exact. + expect(getGenerationDimensions({ base: 'external', type: 'external_image_generator' }).grid).toBe(1); expect(clampDimension(888, getGenerationDimensions({ base: 'flux2', type: 'main' }).grid)).toBe(896); }); }); diff --git a/invokeai/frontend/webv2/src/features/generation/core/settings.ts b/invokeai/frontend/webv2/src/features/generation/core/settings.ts index 4b5cf3c4809..28601d09f43 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/settings.ts @@ -28,6 +28,14 @@ import { KREA2_REBALANCE_WEIGHT_COUNT, } from './conditioningRebalance'; import { isDynamicPromptsSeedBehaviour, sanitizeMaxPrompts, sanitizeSampleSeed } from './dynamicPrompts'; +import { + GEMINI_TEMPERATURE_MAX, + GEMINI_TEMPERATURE_MIN, + isGeminiThinkingLevel, + isOpenAIImageBackground, + isOpenAIImageQuality, + isOpenAIInputFidelity, +} from './externalProvider'; import { clampPidSteps, DEFAULT_PID_STEPS, isPidMode } from './pid'; import { isCanonicalPromptTemplateSnapshot, sanitizePromptTemplateSnapshot } from './promptTemplates'; import { cloneCroppableImage, normalizeCroppableImage } from './referenceImage'; @@ -794,6 +802,20 @@ export const normalizeGenerateSettings = (values: unknown): GenerateSettings | n pidDecoderModel: getModelIdentifierOrNull(values.pidDecoderModel), gemma2EncoderModel: getModelIdentifierOrNull(values.gemma2EncoderModel), pidSteps: clampPidSteps(hasFiniteNumber(values, 'pidSteps') ? (values.pidSteps as number) : DEFAULT_PID_STEPS), + externalImageSize: + typeof values.externalImageSize === 'string' && values.externalImageSize !== '' ? values.externalImageSize : null, + openaiQuality: isOpenAIImageQuality(values.openaiQuality) ? values.openaiQuality : 'auto', + openaiBackground: isOpenAIImageBackground(values.openaiBackground) ? values.openaiBackground : 'auto', + openaiInputFidelity: isOpenAIInputFidelity(values.openaiInputFidelity) ? values.openaiInputFidelity : null, + geminiTemperature: + hasFiniteNumber(values, 'geminiTemperature') && + (values.geminiTemperature as number) >= GEMINI_TEMPERATURE_MIN && + (values.geminiTemperature as number) <= GEMINI_TEMPERATURE_MAX + ? (values.geminiTemperature as number) + : null, + geminiThinkingLevel: isGeminiThinkingLevel(values.geminiThinkingLevel) ? values.geminiThinkingLevel : null, + seedreamWatermark: values.seedreamWatermark === true, + seedreamOptimizePrompt: values.seedreamOptimizePrompt === true, width, }; }; diff --git a/invokeai/frontend/webv2/src/features/generation/core/types.ts b/invokeai/frontend/webv2/src/features/generation/core/types.ts index d32d37543de..b3f11764a50 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/types.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/types.ts @@ -30,18 +30,43 @@ export type MainModelConfig = ModelIdentifierConfig & { } | null; }; +export type ExternalGenerationMode = 'txt2img' | 'img2img' | 'inpaint'; + +export interface ExternalImageSize { + width: number; + height: number; +} + +/** A provider's named size at one aspect ratio, e.g. Gemini's `1K` at `16:9`. */ +export interface ExternalResolutionPreset { + label: string; + aspect_ratio: string; + image_size: string; + width: number; + height: number; +} + +/** The capability matrix the backend records for an external model (`ExternalModelCapabilities`). */ +export interface ExternalModelCapabilities { + modes?: string[]; + supports_seed?: boolean; + supports_negative_prompt?: boolean; + supports_reference_images?: boolean; + max_reference_images?: number | null; + max_images_per_request?: number | null; + max_image_size?: ExternalImageSize | null; + allowed_aspect_ratios?: string[] | null; + /** Fixed output size per aspect ratio, for providers that accept no free-form dimensions. */ + aspect_ratio_sizes?: Record | null; + resolution_presets?: ExternalResolutionPreset[] | null; +} + export type ExternalImageGeneratorModelConfig = ModelIdentifierConfig & { base: 'external'; type: 'external_image_generator'; format: 'external_api'; provider_id?: string; - capabilities?: { - modes?: string[]; - supports_seed?: boolean; - supports_negative_prompt?: boolean; - supports_reference_images?: boolean; - max_reference_images?: number | null; - } | null; + capabilities?: ExternalModelCapabilities | null; default_settings?: { width?: number | null; height?: number | null; @@ -269,8 +294,24 @@ export interface GenerateSettings { /** The shared Gemma-2 caption encoder PiD conditions its decode on. */ gemma2EncoderModel: ComponentModelConfig | null; pidSteps: number; + /** A provider's named size (e.g. `1K`); sent only when the selected model lists it in a resolution preset. */ + externalImageSize: string | null; + openaiQuality: OpenAIImageQuality; + openaiBackground: OpenAIImageBackground; + /** Null lets OpenAI pick; only meaningful with image inputs. */ + openaiInputFidelity: OpenAIInputFidelity | null; + /** Null uses the provider default. */ + geminiTemperature: number | null; + geminiThinkingLevel: GeminiThinkingLevel | null; + seedreamWatermark: boolean; + seedreamOptimizePrompt: boolean; } +export type OpenAIImageQuality = 'auto' | 'high' | 'medium' | 'low'; +export type OpenAIImageBackground = 'auto' | 'transparent' | 'opaque'; +export type OpenAIInputFidelity = 'low' | 'high'; +export type GeminiThinkingLevel = 'minimal' | 'high'; + /** * - `off`: ordinary VAE decode. * - `fit`: generate at the requested size, decode 4x, downscale back to it. diff --git a/invokeai/frontend/webv2/src/features/generation/settings.ts b/invokeai/frontend/webv2/src/features/generation/settings.ts index 8561bbc1a9b..192b0535b9a 100644 --- a/invokeai/frontend/webv2/src/features/generation/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/settings.ts @@ -54,6 +54,16 @@ export { type GenerateModelSelectionResult, } from './core/baseGenerationPolicies'; export { hasArchitectureCapabilities } from './core/architectureCapabilities'; +export { + GEMINI_TEMPERATURE_MAX, + GEMINI_TEMPERATURE_MIN, + getExternalProviderOptionKeys, + getExternalResolutionPresets, + isGeminiThinkingLevel, + isOpenAIImageBackground, + isOpenAIImageQuality, + isOpenAIInputFidelity, +} from './core/externalProvider'; export { resolveGenerateWidgetValues, type ResolvedGenerateWidgetValues, diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx index 174c5ad2bf1..53f0e66bc65 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx @@ -26,6 +26,7 @@ import { useGenerationUi, } from './GenerationUiContext'; import { AspectRatioLockButton, AspectRatioSelect } from './shared/AspectRatioSelect'; +import { ExternalSizeSelect } from './shared/ExternalSizeSelect'; import { GenerateCollapsibleSection } from './shared/GenerateCollapsibleSection'; import { GenerateFieldContextMenu } from './shared/GenerateFieldContextMenu'; @@ -247,6 +248,7 @@ const selectDimensionSettings = pickGenerateSettings([ 'aspectRatioId', 'aspectRatioIsLocked', 'aspectRatioValue', + 'externalImageSize', 'height', 'width', ]); @@ -519,6 +521,17 @@ export const GenerateDimensionFields = ({ + {selectedModel?.type === 'external_image_generator' ? ( + + ) : null} ({ + useGenerationUi: () => ({ + project: { invocationSourceId: 'generate' }, + sectionPreferences: { sectionsOpen: { providerOptions: true }, setSectionOpen: vi.fn() }, + }), +})); + +const i18n = createInstance(); +void i18n.use(initReactI18next).init({ + fallbackLng: 'en', + initAsync: false, + lng: 'en', + resources: { + en: { + translation: { + common: { scrubber: { editValue: 'Edit {{label}}' } }, + widgets: { + generate: { + override: 'Override', + overridesCount_one: '{{count}} override', + overridesCount_other: '{{count}} overrides', + providerOptions: { + geminiTemperature: 'Temperature', + geminiThinkingLevel: 'Thinking level', + geminiThinkingLevelOptions: { high: 'High', minimal: 'Minimal' }, + openaiBackground: 'Background', + openaiBackgroundOptions: { auto: 'Auto', opaque: 'Opaque', transparent: 'Transparent' }, + openaiInputFidelity: 'Input fidelity', + openaiInputFidelityOptions: { high: 'High', low: 'Low' }, + openaiQuality: 'Quality', + openaiQualityOptions: { auto: 'Auto', high: 'High', low: 'Low', medium: 'Medium' }, + providerDefault: 'Provider default', + seedreamOptimizePrompt: 'Optimize prompt', + seedreamWatermark: 'Watermark', + title: 'Provider options', + }, + }, + }, + }, + }, + }, +}); + +const external = (providerId: string, capabilities: Record): GenerateModelConfig => + ({ + base: 'external', + capabilities, + format: 'external_api', + key: `${providerId}-model`, + name: providerId, + provider_id: providerId, + type: 'external_image_generator', + }) as GenerateModelConfig; +const sd1Model: MainModelConfig = { base: 'sd-1', key: 'sd1', name: 'SD 1.5', type: 'main' }; + +let host: HTMLDivElement | null = null; +let root: Root | null = null; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const settle = (action: () => void): Promise => + act(async () => { + action(); + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 50); + }); + }); + +const render = async (model: GenerateModelConfig, settings: Partial = {}) => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + const onCommit = vi.fn(); + + await settle(() => { + root?.render( + + + + + + ); + }); + + return onCommit; +}; + +const selectTriggers = () => [ + ...(host?.querySelectorAll('[data-scope="select"][data-part="trigger"]') ?? []), +]; +const selectNames = () => + selectTriggers().map((trigger) => + (trigger.getAttribute('aria-labelledby') ?? '') + .split(' ') + .map((id) => document.getElementById(id)?.textContent?.trim() ?? '') + .join(' ') + .trim() + ); +const switches = () => [...(host?.querySelectorAll('input[type="checkbox"]') ?? [])]; +const option = (label: string) => + [...document.querySelectorAll('[role="option"]')].find((item) => item.textContent === label) ?? null; + +afterEach(async () => { + await settle(() => root?.unmount()); + host?.remove(); + host = null; + root = null; +}); + +describe('GenerateProviderOptionsSection', () => { + it('renders nothing for local models and providers without options', async () => { + await render(sd1Model); + expect(host?.childElementCount).toBe(0); + + await settle(() => root?.unmount()); + await render(external('alibabacloud', { modes: ['txt2img'] })); + expect(host?.childElementCount).toBe(0); + }); + + it('offers OpenAI quality and background, adding input fidelity only for image inputs', async () => { + await render(external('openai', { modes: ['txt2img'] })); + expect(selectNames()).toEqual(['Quality', 'Background']); + + await settle(() => root?.unmount()); + const onCommit = await render(external('openai', { modes: ['txt2img', 'img2img'] })); + expect(selectNames()).toEqual(['Quality', 'Background', 'Input fidelity']); + + await settle(() => selectTriggers()[1]?.click()); + await settle(() => option('Transparent')?.click()); + expect(onCommit).toHaveBeenCalledWith({ openaiBackground: 'transparent' }); + + await settle(() => selectTriggers()[2]?.click()); + await settle(() => option('High')?.click()); + expect(onCommit).toHaveBeenCalledWith({ openaiInputFidelity: 'high' }); + }); + + it('lets Gemini temperature be overridden and thinking level return to the provider default', async () => { + const onCommit = await render(external('gemini', { modes: ['txt2img'] }), { geminiThinkingLevel: 'high' }); + + expect(selectNames()).toEqual(['Thinking level']); + // Temperature starts at the provider default: no slider until overridden. + expect(host?.querySelector('input[aria-label="Temperature"]')).toBeNull(); + + await settle(() => switches()[0]?.click()); + expect(onCommit).toHaveBeenCalledWith({ geminiTemperature: 1 }); + + await settle(() => selectTriggers()[0]?.click()); + await settle(() => option('Provider default')?.click()); + expect(onCommit).toHaveBeenCalledWith({ geminiThinkingLevel: null }); + }); + + it('toggles the Seedream flags and counts them as overrides', async () => { + const onCommit = await render(external('seedream', { modes: ['txt2img'] }), { seedreamWatermark: true }); + + expect(host?.textContent).toContain('1 override'); + const [watermark, optimizePrompt] = switches(); + + expect(watermark?.checked).toBe(true); + expect(optimizePrompt?.checked).toBe(false); + + await settle(() => optimizePrompt?.click()); + expect(onCommit).toHaveBeenCalledWith({ seedreamOptimizePrompt: true }); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateProviderOptionsSection.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateProviderOptionsSection.tsx new file mode 100644 index 00000000000..56a12cdfdb7 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateProviderOptionsSection.tsx @@ -0,0 +1,260 @@ +/* oxlint-disable react-perf/jsx-no-new-object-as-prop, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-array-as-prop, react-perf/jsx-no-jsx-as-prop */ +import type { GenerateModelConfig, GenerateSettings } from '@features/generation/core/types'; + +import { Badge, createListCollection, Stack } from '@chakra-ui/react'; +import { + DEFAULT_GEMINI_TEMPERATURE, + GEMINI_TEMPERATURE_MAX, + GEMINI_TEMPERATURE_MIN, + GEMINI_THINKING_LEVELS, + getExternalProviderOptionKeys, + isGeminiThinkingLevel, + isOpenAIImageBackground, + isOpenAIImageQuality, + isOpenAIInputFidelity, + OPENAI_IMAGE_BACKGROUNDS, + OPENAI_IMAGE_QUALITIES, + OPENAI_INPUT_FIDELITIES, +} from '@features/generation/core/externalProvider'; +import { useExternalStoreSelector } from '@platform/state/selectors'; +import { Field } from '@platform/ui/Field'; +import { ScrubberField } from '@platform/ui/ScrubberField'; +import { Select } from '@platform/ui/Select'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { type GenerateDraft, pickGenerateSettings } from './generateDebounce'; +import { GenerateCollapsibleSection } from './shared/GenerateCollapsibleSection'; +import { GenerateToggleSwitch } from './shared/GenerateToggleSwitch'; + +interface GenerateProviderOptionsSectionProps { + draft: GenerateDraft; + selectedModel: GenerateModelConfig | undefined; + onCommit: (patch: Partial) => void; +} + +const selectProviderSettings = pickGenerateSettings([ + 'geminiTemperature', + 'geminiThinkingLevel', + 'openaiBackground', + 'openaiInputFidelity', + 'openaiQuality', + 'seedreamOptimizePrompt', + 'seedreamWatermark', +]); + +/** The empty value stands for "let the provider decide" on the optional selects. */ +const PROVIDER_DEFAULT = ''; + +/** Options a hosted provider's node accepts beyond the shared prompt/size/seed fields; hidden for local models. */ +export const GenerateProviderOptionsSection = ({ + draft, + onCommit, + selectedModel, +}: GenerateProviderOptionsSectionProps) => { + const { t } = useTranslation(); + const settings = useExternalStoreSelector(draft.subscribe, draft.getSnapshot, selectProviderSettings); + const optionKeys = getExternalProviderOptionKeys(selectedModel); + const collections = useMemo( + () => ({ + background: createListCollection({ + items: OPENAI_IMAGE_BACKGROUNDS.map((value) => ({ + label: t(`widgets.generate.providerOptions.openaiBackgroundOptions.${value}`), + value, + })), + }), + inputFidelity: createListCollection({ + items: [ + { label: t('widgets.generate.providerOptions.providerDefault'), value: PROVIDER_DEFAULT }, + ...OPENAI_INPUT_FIDELITIES.map((value) => ({ + label: t(`widgets.generate.providerOptions.openaiInputFidelityOptions.${value}`), + value, + })), + ], + }), + quality: createListCollection({ + items: OPENAI_IMAGE_QUALITIES.map((value) => ({ + label: t(`widgets.generate.providerOptions.openaiQualityOptions.${value}`), + value, + })), + }), + thinkingLevel: createListCollection({ + items: [ + { label: t('widgets.generate.providerOptions.providerDefault'), value: PROVIDER_DEFAULT }, + ...GEMINI_THINKING_LEVELS.map((value) => ({ + label: t(`widgets.generate.providerOptions.geminiThinkingLevelOptions.${value}`), + value, + })), + ], + }), + }), + [t] + ); + + if (optionKeys.length === 0) { + return null; + } + + // Count decisions away from the provider defaults, as the Advanced section does. + const overrideCount = optionKeys.filter((key) => { + switch (key) { + case 'openaiQuality': + case 'openaiBackground': + return settings[key] !== 'auto'; + case 'seedreamWatermark': + case 'seedreamOptimizePrompt': + return settings[key]; + default: + return settings[key] !== null; + } + }).length; + + return ( + 0 ? {t('widgets.generate.overridesCount', { count: overrideCount })} : null + } + sectionId="providerOptions" + > + + {optionKeys.includes('openaiQuality') ? ( + + { + if (isOpenAIImageBackground(value[0])) { + onCommit({ openaiBackground: value[0] }); + } + }} + /> + + ) : null} + {optionKeys.includes('openaiInputFidelity') ? ( + + { + const level = value[0]; + + if (level === PROVIDER_DEFAULT) { + onCommit({ geminiThinkingLevel: null }); + } else if (isGeminiThinkingLevel(level)) { + onCommit({ geminiThinkingLevel: level }); + } + }} + /> + + ) : null} + {optionKeys.includes('seedreamWatermark') ? ( + + onCommit({ seedreamWatermark: checked })} + /> + + ) : null} + {optionKeys.includes('seedreamOptimizePrompt') ? ( + + onCommit({ seedreamOptimizePrompt: checked })} + /> + + ) : null} + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx index 86d79bbe0c8..e7b8c4283bf 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx @@ -22,6 +22,7 @@ import { useRegisterGenerateDraftFlusher } from './generateDraftRegistry'; import { getSettingsWithLatestPromptFields } from './generateFormViewModel'; import { GenerateGuidanceSection } from './GenerateGuidanceSection'; import { GenerateModelCard } from './GenerateModelCard'; +import { GenerateProviderOptionsSection } from './GenerateProviderOptionsSection'; import { GenerateRenderSection } from './GenerateRenderSection'; import { GeneratePromptFields } from './promptFields'; @@ -278,6 +279,8 @@ export const GenerateSettingsForm = ({ onCommitImmediate={commitPatchImmediately} /> + + ): GenerateModelConfig => + ({ + base: 'external', + capabilities, + format: 'external_api', + key: 'external', + name: 'External', + provider_id: 'gemini', + type: 'external_image_generator', + }) as GenerateModelConfig; + +const presetModel = external({ + modes: ['txt2img'], + resolution_presets: [ + { aspect_ratio: '1:1', height: 1024, image_size: '1K', label: '1:1 (1K)', width: 1024 }, + { aspect_ratio: '16:9', height: 1152, image_size: '2K', label: '16:9 (2K)', width: 2048 }, + ], +}); +const ratioModel = external({ + aspect_ratio_sizes: { '1:1': { height: 1024, width: 1024 }, '7:4': { height: 1024, width: 1792 } }, + modes: ['txt2img'], +}); + +let host: HTMLDivElement | null = null; +let root: Root | null = null; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const settle = (action: () => void): Promise => + act(async () => { + action(); + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 50); + }); + }); + +const render = async ( + model: GenerateModelConfig, + settings: { externalImageSize: string | null; height: number; width: number } +) => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + const onCommit = vi.fn(); + + await settle(() => { + root?.render( + + + + + + ); + }); + + return onCommit; +}; + +const trigger = () => host?.querySelector('[data-scope="select"][data-part="trigger"]') ?? null; +const option = (label: string) => + [...document.querySelectorAll('[role="option"]')].find((item) => item.textContent === label) ?? null; + +afterEach(async () => { + await settle(() => root?.unmount()); + host?.remove(); + host = null; + root = null; +}); + +describe('ExternalSizeSelect', () => { + it('renders nothing for providers without a size table', async () => { + await render(external({ modes: ['txt2img'] }), { externalImageSize: null, height: 1024, width: 1024 }); + + expect(host?.childElementCount).toBe(0); + }); + + it('applies a named preset as a locked size with its image size', async () => { + const onCommit = await render(presetModel, { externalImageSize: '1K', height: 1024, width: 1024 }); + + expect(trigger()?.textContent).toContain('1:1 (1K)'); + await settle(() => trigger()?.click()); + await settle(() => option('16:9 (2K)')?.click()); + + expect(onCommit).toHaveBeenCalledWith({ + aspectRatioId: '16:9', + aspectRatioIsLocked: true, + aspectRatioValue: 2048 / 1152, + externalImageSize: '2K', + height: 1152, + width: 2048, + }); + }); + + it('shows a hand-typed size as custom', async () => { + await render(presetModel, { externalImageSize: '1K', height: 1000, width: 1024 }); + + expect(trigger()?.textContent).toContain('Custom'); + }); + + it('applies a fixed ratio size, falling back to a free ratio id for shapes outside the preset list', async () => { + const onCommit = await render(ratioModel, { externalImageSize: null, height: 1024, width: 1024 }); + + expect(trigger()?.textContent).toContain('1:1 (1024×1024)'); + await settle(() => trigger()?.click()); + await settle(() => option('7:4 (1792×1024)')?.click()); + + expect(onCommit).toHaveBeenCalledWith({ + aspectRatioId: 'Free', + aspectRatioIsLocked: true, + aspectRatioValue: 1792 / 1024, + externalImageSize: null, + height: 1024, + width: 1792, + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/shared/ExternalSizeSelect.tsx b/invokeai/frontend/webv2/src/features/generation/ui/shared/ExternalSizeSelect.tsx new file mode 100644 index 00000000000..ce62b7caf6a --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/ui/shared/ExternalSizeSelect.tsx @@ -0,0 +1,101 @@ +/* oxlint-disable react-perf/jsx-no-new-object-as-prop, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-array-as-prop */ +import type { GenerateModelConfig, GenerateSettings } from '@features/generation/core/types'; + +import { createListCollection } from '@chakra-ui/react'; +import { + findExternalResolutionPreset, + getExternalAspectRatioSizes, + getExternalResolutionPresets, +} from '@features/generation/core/externalProvider'; +import { isAspectRatioId } from '@features/generation/core/settings'; +import { Field } from '@platform/ui/Field'; +import { Select } from '@platform/ui/Select'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type SizeSettings = Pick; + +interface ExternalSizeSelectProps { + model: GenerateModelConfig | undefined; + settings: SizeSettings; + onCommit: (patch: Partial) => void; +} + +const presetKey = (preset: { aspect_ratio: string; image_size: string }): string => + `${preset.aspect_ratio}|${preset.image_size}`; + +/** The size patch a provider entry implies; its ratio is locked so the width/height fields keep it. */ +const sizePatch = (ratio: string, width: number, height: number): Partial => ({ + aspectRatioId: isAspectRatioId(ratio) ? ratio : 'Free', + aspectRatioIsLocked: true, + aspectRatioValue: height > 0 ? width / height : 1, + height, + width, +}); + +/** + * A hosted provider's own size table: named sizes per ratio (Gemini 3's `1K`/`2K`/`4K`), or one fixed size per + * ratio. Renders nothing for free-form providers and local models. A size typed by hand shows as custom. + */ +export const ExternalSizeSelect = ({ model, onCommit, settings }: ExternalSizeSelectProps) => { + const { t } = useTranslation(); + const presets = getExternalResolutionPresets(model); + const ratioSizes = getExternalAspectRatioSizes(model); + const collection = useMemo( + () => + createListCollection({ + items: + presets.length > 0 + ? presets.map((preset) => ({ label: preset.label, value: presetKey(preset) })) + : ratioSizes.map(([ratio, size]) => ({ label: `${ratio} (${size.width}×${size.height})`, value: ratio })), + }), + [presets, ratioSizes] + ); + + if (presets.length === 0 && ratioSizes.length === 0) { + return null; + } + + const currentPreset = presets.length > 0 ? findExternalResolutionPreset(settings, model) : null; + const currentRatio = + presets.length > 0 + ? null + : (ratioSizes.find(([, size]) => size.width === settings.width && size.height === settings.height)?.[0] ?? null); + const value = currentPreset ? [presetKey(currentPreset)] : currentRatio ? [currentRatio] : []; + + return ( + + setField('providerId', event.currentTarget.value)} + /> + + + setField('providerModelId', event.currentTarget.value)} + /> + + + + + {t('models.externalFields.supportedModes')} + + + {MODE_FLAGS.map(({ key, labelKey }) => ( + setField(key, event.checked === true)} + > + + + {t(labelKey)} + + ))} + + + {t('models.externalFields.supportedModesHelp')} + + + + {SUPPORT_FLAGS.map(({ key, labelKey }) => ( + + + + {t(labelKey)} + + setField(key, event.checked)} + > + + + + + {t(labelKey)} + + + + ))} + + + {LIMIT_FIELDS.map((field) => numberField(field, 'models.externalFields.unlimited'))} + + + {DEFAULT_FIELDS.map((field) => numberField(field, 'models.externalFields.providerDefault'))} + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/models/ui/detail/ModelDetail.tsx b/invokeai/frontend/webv2/src/features/models/ui/detail/ModelDetail.tsx index a354aa5f32e..178633378b2 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/detail/ModelDetail.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/detail/ModelDetail.tsx @@ -28,6 +28,8 @@ import { useTranslation } from 'react-i18next'; import { CpuOnlySetting, supportsCpuOnlySetting } from './CpuOnlySetting'; import { supportsDefaultSettings, type DefaultSettingsModel } from './defaultSettingsFields'; import { DefaultSettingsSection } from './DefaultSettingsSection'; +import { supportsExternalModelSettings, type ExternalModelSettingsModel } from './externalModelFields'; +import { ExternalModelSection } from './ExternalModelSection'; import { MissingFileBadge, ModelBaseBadge, ModelFormatBadge } from './ModelBadges'; import { ModelEditForm } from './ModelEditForm'; import { ModelImageUpload } from './ModelImageUpload'; @@ -122,6 +124,22 @@ const selectDefaultSettingsModel = (snapshot: ModelsSnapshot, modelKey: string): : null; }; +const selectExternalModel = (snapshot: ModelsSnapshot, modelKey: string): ExternalModelSettingsModel | null => { + const model = findModel(snapshot, modelKey); + + return model + ? { + capabilities: model.capabilities, + default_settings: model.default_settings, + key: model.key, + name: model.name, + provider_id: model.provider_id, + provider_model_id: model.provider_model_id, + type: model.type, + } + : null; +}; + const selectTriggerPhrasesModel = (snapshot: ModelsSnapshot, modelKey: string): TriggerPhrasesModel | null => { const model = findModel(snapshot, modelKey); @@ -174,6 +192,13 @@ export const ModelDetail = ({ ) : null} + {supportsExternalModelSettings(model) ? ( + <> + + + + ) : null} + {supportsDefaultSettings(model) ? ( <> @@ -358,6 +383,24 @@ const DefaultSettingsSectionContainer = memo(function DefaultSettingsSectionCont ); }); +const ExternalModelSectionContainer = memo(function ExternalModelSectionContainer({ modelKey }: { modelKey: string }) { + const notify = useNotify(); + const { t } = useTranslation(); + const model = useModelsSelector((snapshot) => selectExternalModel(snapshot, modelKey)); + + if (!model || !supportsExternalModelSettings(model)) { + return null; + } + + return ( + notify.error(t('models.externalProvider'), message)} + onSaved={() => notify.success(t('models.externalSettingsSaved'))} + /> + ); +}); + const CpuOnlySettingContainer = memo(function CpuOnlySettingContainer({ modelKey }: { modelKey: string }) { const notify = useNotify(); const { t } = useTranslation(); diff --git a/invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.test.ts b/invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.test.ts new file mode 100644 index 00000000000..e7f239db5ea --- /dev/null +++ b/invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.test.ts @@ -0,0 +1,120 @@ +import type { TFunction } from 'i18next'; + +import { describe, expect, it } from 'vitest'; + +import type { ExternalModelSettingsModel } from './externalModelFields'; + +import { toExternalModelChanges, toExternalModelValues, validateExternalModel } from './externalModelFields'; + +const t = ((key: string) => key) as unknown as TFunction; + +const geminiModel: ExternalModelSettingsModel = { + capabilities: { + allowed_aspect_ratios: ['1:1', '16:9'], + aspect_ratio_sizes: { '1:1': { height: 1024, width: 1024 } }, + mask_format: 'none', + max_image_size: { height: 4096, width: 4096 }, + max_images_per_request: 1, + max_reference_images: 14, + modes: ['txt2img'], + resolution_presets: [{ aspect_ratio: '1:1', height: 1024, image_size: '1K', label: '1:1 (1K)', width: 1024 }], + supports_reference_images: true, + supports_seed: true, + }, + default_settings: { height: 1024, num_images: 1, width: 1024 }, + key: 'gemini-3-pro', + name: 'Gemini 3 Pro', + provider_id: 'gemini', + provider_model_id: 'gemini-3-pro-image-preview', + type: 'external_image_generator', +}; + +describe('external model record mapping', () => { + it('reads the record into flat editor values, defaulting the fields the backend defaults', () => { + expect(toExternalModelValues(geminiModel)).toEqual({ + defaultHeight: 1024, + defaultNumImages: 1, + defaultWidth: 1024, + maxImageHeight: 4096, + maxImageWidth: 4096, + maxImagesPerRequest: 1, + maxReferenceImages: 14, + modeImg2img: false, + modeInpaint: false, + modeTxt2img: true, + providerId: 'gemini', + providerModelId: 'gemini-3-pro-image-preview', + supportsNegativePrompt: true, + supportsReferenceImages: true, + supportsSeed: true, + }); + // A record with no capability matrix reads as the backend's defaults: txt2img, negative prompt, nothing else. + expect( + toExternalModelValues({ ...geminiModel, capabilities: null, default_settings: null, provider_id: undefined }) + ).toMatchObject({ + defaultWidth: null, + maxImagesPerRequest: null, + modeTxt2img: true, + providerId: '', + supportsNegativePrompt: true, + supportsReferenceImages: false, + supportsSeed: false, + }); + }); + + it('writes a whole capability matrix, keeping the tables the editor does not show', () => { + const changes = toExternalModelChanges( + { + ...toExternalModelValues(geminiModel), + maxImageHeight: null, + maxImageWidth: null, + maxReferenceImages: null, + modeImg2img: true, + modeInpaint: true, + providerId: ' gemini ', + supportsSeed: false, + }, + geminiModel + ); + + expect(changes.provider_id).toBe('gemini'); + expect(changes.capabilities).toEqual({ + allowed_aspect_ratios: ['1:1', '16:9'], + aspect_ratio_sizes: { '1:1': { height: 1024, width: 1024 } }, + mask_format: 'none', + max_image_size: null, + max_images_per_request: 1, + max_reference_images: null, + modes: ['txt2img', 'img2img', 'inpaint'], + resolution_presets: [{ aspect_ratio: '1:1', height: 1024, image_size: '1K', label: '1:1 (1K)', width: 1024 }], + supports_negative_prompt: true, + supports_reference_images: true, + supports_seed: false, + }); + expect(changes.default_settings).toEqual({ height: 1024, num_images: 1, width: 1024 }); + }); + + it('clears the defaults record when every default is lifted', () => { + const changes = toExternalModelChanges( + { ...toExternalModelValues(geminiModel), defaultHeight: null, defaultNumImages: null, defaultWidth: null }, + geminiModel + ); + + expect(changes.default_settings).toBeNull(); + }); + + it('rejects an empty route, no modes, half a max size, and non-positive limits', () => { + const values = toExternalModelValues(geminiModel); + + expect(validateExternalModel(values, t)).toBeNull(); + expect(validateExternalModel({ ...values, providerModelId: ' ' }, t)).toBe('Provider model ID is required.'); + expect(validateExternalModel({ ...values, modeTxt2img: false }, t)).toBe('Choose at least one supported mode.'); + expect(validateExternalModel({ ...values, maxImageHeight: null }, t)).toBe( + 'Set both a max width and a max height, or neither.' + ); + expect(validateExternalModel({ ...values, maxImagesPerRequest: 0 }, t)).toBe( + 'Images per request must be greater than 0.' + ); + expect(validateExternalModel({ ...values, defaultWidth: 1024.5 }, t)).toBe('Default width must be a whole number.'); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.ts b/invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.ts new file mode 100644 index 00000000000..3dfd3f36986 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.ts @@ -0,0 +1,83 @@ +import type { ExternalModelFormValues } from '@features/models/core/schemas'; +import type { ExternalModelCapabilities, ModelConfig, ModelRecordChanges } from '@features/models/core/types'; +import type { TFunction } from 'i18next'; + +import { externalModelSchema } from '@features/models/core/schemas'; + +export type ExternalModelSettingsModel = Pick< + ModelConfig, + 'capabilities' | 'default_settings' | 'key' | 'name' | 'provider_id' | 'provider_model_id' | 'type' +>; + +export const supportsExternalModelSettings = (model: Pick): boolean => + model.type === 'external_image_generator'; + +const toOptionalInteger = (value: number | null | undefined): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +/** The editor's flat values for a record: what a draft of the external surface is a difference from. */ +export const toExternalModelValues = (model: ExternalModelSettingsModel): ExternalModelFormValues => { + const capabilities = model.capabilities ?? {}; + const modes = new Set(capabilities.modes ?? ['txt2img']); + + return { + defaultHeight: toOptionalInteger(model.default_settings?.height), + defaultNumImages: toOptionalInteger(model.default_settings?.num_images), + defaultWidth: toOptionalInteger(model.default_settings?.width), + maxImageHeight: toOptionalInteger(capabilities.max_image_size?.height), + maxImageWidth: toOptionalInteger(capabilities.max_image_size?.width), + maxImagesPerRequest: toOptionalInteger(capabilities.max_images_per_request), + maxReferenceImages: toOptionalInteger(capabilities.max_reference_images), + modeImg2img: modes.has('img2img'), + modeInpaint: modes.has('inpaint'), + modeTxt2img: modes.has('txt2img'), + providerId: model.provider_id ?? '', + providerModelId: model.provider_model_id ?? '', + supportsNegativePrompt: capabilities.supports_negative_prompt ?? true, + supportsReferenceImages: capabilities.supports_reference_images === true, + supportsSeed: capabilities.supports_seed === true, + }; +}; + +/** + * The PATCH body for saved values. The backend replaces `capabilities` as a whole, so the fields this editor does not + * show (aspect-ratio tables, presets, mask format) are carried over from the current record. + */ +export const toExternalModelChanges = ( + values: ExternalModelFormValues, + model: ExternalModelSettingsModel +): Pick => { + const capabilities: ExternalModelCapabilities = { + ...model.capabilities, + max_image_size: + values.maxImageWidth !== null && values.maxImageHeight !== null + ? { height: values.maxImageHeight, width: values.maxImageWidth } + : null, + max_images_per_request: values.maxImagesPerRequest, + max_reference_images: values.maxReferenceImages, + modes: [ + ...(values.modeTxt2img ? (['txt2img'] as const) : []), + ...(values.modeImg2img ? (['img2img'] as const) : []), + ...(values.modeInpaint ? (['inpaint'] as const) : []), + ], + supports_negative_prompt: values.supportsNegativePrompt, + supports_reference_images: values.supportsReferenceImages, + supports_seed: values.supportsSeed, + }; + const hasDefaults = values.defaultWidth !== null || values.defaultHeight !== null || values.defaultNumImages !== null; + + return { + capabilities, + default_settings: hasDefaults + ? { height: values.defaultHeight, num_images: values.defaultNumImages, width: values.defaultWidth } + : null, + provider_id: values.providerId.trim(), + provider_model_id: values.providerModelId.trim(), + }; +}; + +export const validateExternalModel = (values: ExternalModelFormValues, t: TFunction): string | null => { + const result = externalModelSchema.safeParse(values); + + return result.success ? null : (result.error.issues[0]?.message ?? t('models.invalidExternalSettings')); +}; diff --git a/invokeai/frontend/webv2/src/features/models/ui/modelDraftsStore.test.ts b/invokeai/frontend/webv2/src/features/models/ui/modelDraftsStore.test.ts index ae4cff9f30e..6997cd4c513 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/modelDraftsStore.test.ts +++ b/invokeai/frontend/webv2/src/features/models/ui/modelDraftsStore.test.ts @@ -47,6 +47,21 @@ describe('model drafts store', () => { expect(getModelDraft('m')?.identity?.fields).toEqual({ name: 'Draft 2' }); }); + it('counts hosted-provider record edits as unsaved changes and discards them like defaults', () => { + recordModelDraftFields( + 'm', + 'external', + { modeInpaint: true, providerId: 'openai' }, + { modeInpaint: false, providerId: 'openai' } + ); + + expect(getModelDraft('m')?.external?.fields).toEqual({ modeInpaint: true }); + expect(hasUnsavedModelChanges(getModelDraft('m'))).toBe(true); + + discardModelDraft('m', 'external'); + expect(getModelDraft('m')).toBeUndefined(); + }); + it('treats unset and null default settings as the same value', () => { recordModelDraftFields('m', 'defaults', { cfgScale: null, steps: 30 }, { steps: 30 }); diff --git a/invokeai/frontend/webv2/src/features/models/ui/modelDraftsStore.ts b/invokeai/frontend/webv2/src/features/models/ui/modelDraftsStore.ts index 1265343f89a..49cf108c888 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/modelDraftsStore.ts +++ b/invokeai/frontend/webv2/src/features/models/ui/modelDraftsStore.ts @@ -2,6 +2,7 @@ import type { ModelEditFormValues } from '@features/models/core/schemas'; import type { ModelConfig } from '@features/models/core/types'; import { getModelsSnapshot, subscribeModels } from '@features/models/data/modelsStore'; +import { toExternalModelValues } from '@features/models/ui/detail/externalModelFields'; import { registerAccountOwnedResource } from '@platform/state/accountLifecycle'; import { createKeyedTransientStore } from '@platform/state/externalStore'; import { useExternalStoreSelector, type EqualityFn } from '@platform/state/selectors'; @@ -36,6 +37,8 @@ export interface ModelIdentityDraft extends ModelFieldsDraft { export interface ModelDraft { /** Default-settings edits; absent when nothing differs from the server and no save is in flight. */ readonly defaults?: ModelFieldsDraft; + /** Hosted-provider record edits, kept like `defaults`. */ + readonly external?: ModelFieldsDraft; /** Present exactly while the identity editor is open, even before anything is edited. */ readonly identity?: ModelIdentityDraft; } @@ -68,9 +71,12 @@ export const toModelIdentityValues = (model: ModelIdentitySource): ModelEditForm const SERVER_VALUES: Record object> = { defaults: (model) => model.default_settings ?? {}, + external: toExternalModelValues, identity: toModelIdentityValues, }; +const FIELD_SURFACES = ['identity', 'defaults', 'external'] as const satisfies readonly ModelDraftSurface[]; + // Unset and null are the same "no value" to the backend and to every form here. const isSameValue = (left: unknown, right: unknown): boolean => Object.is(left ?? null, right ?? null); @@ -93,7 +99,7 @@ export const hasModelDraftConflict = (server: object, draft: ModelFieldsDraft | ); export const hasUnsavedModelChanges = (draft: ModelDraft | undefined): boolean => - hasDraftFields(draft?.identity) || hasDraftFields(draft?.defaults); + FIELD_SURFACES.some((surface) => hasDraftFields(draft?.[surface])); /** * Whether a user value is still an edit: it differs from the server, or from a save in flight (a field typed back @@ -123,7 +129,7 @@ const evictBeyondBound = (): void => { }; const writeDraft = (modelKey: string, draft: ModelDraft, edited = false): void => { - if (draft.identity === undefined && draft.defaults === undefined) { + if (FIELD_SURFACES.every((surface) => draft[surface] === undefined)) { deleteDraft(modelKey); return; } @@ -224,7 +230,7 @@ const syncWithLibrary = (): void => { let draft = store.get(key); - for (const surface of ['identity', 'defaults'] as const) { + for (const surface of FIELD_SURFACES) { const surfaceDraft = draft?.[surface]; if (surfaceDraft !== undefined) { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts index 54dd28668cd..d3b8429764d 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts @@ -120,6 +120,14 @@ const setModel = (project: Project, base: GenerateWidgetValues['model']['base']) ideogram4GuidanceScale: null, ideogram4Mu: null, ideogram4ColorPalette: [], + externalImageSize: null, + openaiQuality: 'auto', + openaiBackground: 'auto', + openaiInputFidelity: null, + geminiTemperature: null, + geminiThinkingLevel: null, + seedreamWatermark: false, + seedreamOptimizePrompt: false, krea2RebalanceEnabled: false, krea2RebalanceMultiplier: 4, krea2RebalanceWeights: '1.0,1.0,1.0,1.0,1.0,1.0,1.0,2.5,5.0,1.1,4.0,1.0', diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts index 83d8be78063..da30143cb74 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts @@ -143,6 +143,14 @@ const createValues = (overrides: Partial = {}): GenerateWi ideogram4GuidanceScale: null, ideogram4Mu: null, ideogram4ColorPalette: [], + externalImageSize: null, + openaiQuality: 'auto', + openaiBackground: 'auto', + openaiInputFidelity: null, + geminiTemperature: null, + geminiThinkingLevel: null, + seedreamWatermark: false, + seedreamOptimizePrompt: false, krea2RebalanceEnabled: false, krea2RebalanceMultiplier: 4, krea2RebalanceWeights: '1.0,1.0,1.0,1.0,1.0,1.0,1.0,2.5,5.0,1.1,4.0,1.0', @@ -567,8 +575,9 @@ describe('image recall', () => { expect(result?.values.aspectRatioId).toBe('2:3'); }); - it('recalls the size for an external generator, which has no architecture row to wait for', () => { - // External providers have no capability row and must remain eligible for dimension recall. + it('recalls the exact size for an external generator, which has no architecture row or grid', () => { + // External providers have no capability row and must remain eligible for dimension recall; their providers + // bucket sizes themselves, so the image's pixel size is kept rather than snapped to a local model grid. const externalModel = { base: 'external', capabilities: { modes: ['txt2img'], supports_seed: true }, @@ -589,11 +598,11 @@ describe('image recall', () => { vaeModels: [], }); - expect(recall()?.values).toMatchObject({ height: 768, width: 512 }); + expect(recall()?.values).toMatchObject({ height: 770, width: 513 }); resetArchitectureCapabilities(); try { - expect(recall()?.values).toMatchObject({ height: 768, width: 512 }); + expect(recall()?.values).toMatchObject({ height: 770, width: 513 }); } finally { setArchitectureCapabilities(architectureCapabilitiesFixture); } @@ -1178,6 +1187,137 @@ describe('image recall', () => { expect(capabilities.remix).toBe(true); }); }); + + describe('external provider options', () => { + const openaiModel = { + base: 'external', + capabilities: { modes: ['txt2img', 'img2img'], supports_reference_images: true }, + format: 'external_api', + key: 'gpt-image', + name: 'GPT Image', + provider_id: 'openai', + type: 'external_image_generator', + } as GenerateWidgetValues['model']; + const geminiModel = { + ...openaiModel, + capabilities: { + modes: ['txt2img'], + resolution_presets: [ + { aspect_ratio: '1:1', height: 1024, image_size: '1K', label: '1:1 (1K)', width: 1024 }, + { aspect_ratio: '1:1', height: 2048, image_size: '2K', label: '1:1 (2K)', width: 2048 }, + ], + }, + key: 'gemini-3', + name: 'Gemini 3', + provider_id: 'gemini', + } as GenerateWidgetValues['model']; + const seedreamModel = { + ...openaiModel, + capabilities: { modes: ['txt2img'] }, + key: 'seedream', + name: 'Seedream', + provider_id: 'seedream', + } as GenerateWidgetValues['model']; + // What the provider nodes record (`_build_output_provider_metadata`) plus the named size the base node records. + const providerMetadata = { + gemini_temperature: 0.4, + gemini_thinking_level: 'high', + image_size: '2K', + openai_background: 'transparent', + openai_input_fidelity: 'high', + openai_quality: 'low', + seedream_optimize_prompt: true, + seedream_watermark: true, + }; + const recall = (model: GenerateWidgetValues['model'], metadata: Record, kind: 'all' | 'remix') => + buildImageRecallSettings({ + currentValues: createValues(), + image, + kind, + metadata: { ...metadata, model: { key: model.key } }, + models: [], + supportedModels: [model], + vaeModels: [], + }); + + it('restores only the options the recalled OpenAI model accepts', () => { + const result = recall(openaiModel, providerMetadata, 'all'); + + expect(result?.values).toMatchObject({ + openaiBackground: 'transparent', + openaiInputFidelity: 'high', + openaiQuality: 'low', + }); + // Another provider's keys and a named size the model does not list stay at the current values. + expect(result?.values).toMatchObject({ + externalImageSize: null, + geminiTemperature: null, + geminiThinkingLevel: null, + seedreamOptimizePrompt: false, + seedreamWatermark: false, + }); + expect(result?.fields).toContain('externalProvider'); + }); + + it('restores Gemini sampling and the named size the model lists, on a remix too', () => { + const result = recall(geminiModel, providerMetadata, 'remix'); + + expect(result?.values).toMatchObject({ + externalImageSize: '2K', + geminiTemperature: 0.4, + geminiThinkingLevel: 'high', + openaiQuality: 'auto', + }); + expect(result?.fields).toContain('externalProvider'); + }); + + it('restores Seedream toggles and drops values the node would reject', () => { + const result = recall( + seedreamModel, + { ...providerMetadata, seedream_optimize_prompt: 'yes', seedream_watermark: false }, + 'all' + ); + + expect(result?.values.seedreamWatermark).toBe(false); + expect(result?.values.seedreamOptimizePrompt).toBe(false); + expect(result?.fields).toContain('externalProvider'); + + const invalid = recall(geminiModel, { gemini_temperature: 9, gemini_thinking_level: 'max' }, 'all'); + + expect(invalid?.values.geminiTemperature).toBeNull(); + expect(invalid?.values.geminiThinkingLevel).toBeNull(); + expect(invalid?.fields).not.toContain('externalProvider'); + }); + + it('skips provider metadata when the image was made with a local model', () => { + const result = buildImageRecallSettings({ + currentValues: createValues(), + image, + kind: 'all', + metadata: { ...providerMetadata, model: { key: sdxlModel.key } }, + models: [], + supportedModels: [sdxlModel], + vaeModels: [], + }); + + expect(result?.values.openaiQuality).toBe('auto'); + expect(result?.fields).not.toContain('externalProvider'); + }); + + it('offers recall for an image whose only recallable delta is a provider option', () => { + const capabilities = getImageRecallCapabilities({ + currentValues: createValues({ model: seedreamModel, modelKey: seedreamModel.key }), + image, + metadata: { seedream_watermark: true }, + models: [], + supportedModels: [], + vaeModels: [], + }); + + expect(capabilities.all).toBe(true); + expect(capabilities.remix).toBe(true); + }); + }); }); /** Remix from the metadata a fully filled graph records, starting from SDXL defaults. */ diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts index 829d4119327..97f02443269 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts @@ -15,6 +15,8 @@ import { deriveAspectRatioId, getCompatibleReferenceImages, getDimensionGrid, + getExternalProviderOptionKeys, + getExternalResolutionPresets, getGenerateModelSelectionResult, getGenerationUiPolicy, isComponentCompatibleWithModel, @@ -28,7 +30,13 @@ import { isMainModelConfig, isModelIdentifierConfig, isVaeCompatibleWithGenerateModel, + isGeminiThinkingLevel, + isOpenAIImageBackground, + isOpenAIImageQuality, + isOpenAIInputFidelity, isValidKrea2RebalanceWeights, + GEMINI_TEMPERATURE_MAX, + GEMINI_TEMPERATURE_MIN, MAX_HIDIFFUSION_RATIO, MIN_HIDIFFUSION_T1_RATIO, normalizeReferenceImages, @@ -73,7 +81,8 @@ type RecalledField = | 'components' | 'loras' | 'referenceImages' - | 'krea2Rebalance'; + | 'krea2Rebalance' + | 'externalProvider'; export type ImageRecallSkipReason = | 'ambiguous' @@ -419,6 +428,92 @@ const getMetadataKrea2Rebalance = ( const hasKrea2Rebalance = (metadata: unknown, model: GenerateModelConfig | undefined): boolean => Object.keys(getMetadataKrea2Rebalance(metadata, model)).length > 0; +/** + * Provider options and the named size, gated on what the effective model's provider accepts: the keys are recorded + * by the provider nodes (`openai_quality`, `gemini_temperature`, ...), so a value only applies to the same provider. + */ +const getMetadataExternalProvider = ( + metadata: unknown, + model: GenerateModelConfig | undefined +): Partial => { + if (model?.type !== 'external_image_generator') { + return {}; + } + + const patch: Partial = {}; + const imageSize = getString(metadata, 'image_size'); + + if (imageSize !== null && getExternalResolutionPresets(model).some((preset) => preset.image_size === imageSize)) { + patch.externalImageSize = imageSize; + } + + for (const key of getExternalProviderOptionKeys(model)) { + switch (key) { + case 'openaiQuality': { + const value = getString(metadata, 'openai_quality'); + + if (isOpenAIImageQuality(value)) { + patch.openaiQuality = value; + } + break; + } + case 'openaiBackground': { + const value = getString(metadata, 'openai_background'); + + if (isOpenAIImageBackground(value)) { + patch.openaiBackground = value; + } + break; + } + case 'openaiInputFidelity': { + const value = getString(metadata, 'openai_input_fidelity'); + + if (isOpenAIInputFidelity(value)) { + patch.openaiInputFidelity = value; + } + break; + } + case 'geminiTemperature': { + const value = getNumber(metadata, 'gemini_temperature'); + + if (value !== null && value >= GEMINI_TEMPERATURE_MIN && value <= GEMINI_TEMPERATURE_MAX) { + patch.geminiTemperature = value; + } + break; + } + case 'geminiThinkingLevel': { + const value = getString(metadata, 'gemini_thinking_level'); + + if (isGeminiThinkingLevel(value)) { + patch.geminiThinkingLevel = value; + } + break; + } + case 'seedreamWatermark': { + const value = getBoolean(metadata, 'seedream_watermark'); + + if (value !== null) { + patch.seedreamWatermark = value; + } + break; + } + case 'seedreamOptimizePrompt': { + const value = getBoolean(metadata, 'seedream_optimize_prompt'); + + if (value !== null) { + patch.seedreamOptimizePrompt = value; + } + break; + } + } + } + + return patch; +}; + +const hasExternalProvider = (metadata: unknown, model: GenerateModelConfig | undefined): boolean => + Object.keys(getMetadataExternalProvider(metadata, model)).length > 0; + type RecalledComponentSetting = keyof Pick< GenerateWidgetValues, | 'clipEmbedModel' @@ -707,6 +802,7 @@ export const getImageRecallCapabilities = ({ const hasReferenceImages = getMetadataReferenceImages(metadata).length > 0; const hasModel = supportedMetadataModel !== null; const hasRebalance = hasKrea2Rebalance(metadata, clipSkipModel); + const hasProviderOptions = hasExternalProvider(metadata, clipSkipModel); const hasConcepts = (getMetadataConcepts(metadata, models, clipSkipModel, supportedMetadataModel).loras?.length ?? 0) > 0; const hasAnyMetadata = @@ -721,7 +817,8 @@ export const getImageRecallCapabilities = ({ hasComponents || hasReferenceImages || hasConcepts || - hasRebalance; + hasRebalance || + hasProviderOptions; const hasNonSeedMetadata = hasModel || hasVae || @@ -733,7 +830,8 @@ export const getImageRecallCapabilities = ({ hasComponents || hasReferenceImages || hasConcepts || - hasRebalance; + hasRebalance || + hasProviderOptions; return { all: hasAnyMetadata && isImageRecallKindAvailable('all'), @@ -803,6 +901,13 @@ export const buildImageRecallSettings = ({ fields.push('krea2Rebalance'); } + const providerPatch = getMetadataExternalProvider(metadata, values.model); + + if (Object.keys(providerPatch).length > 0) { + values = { ...values, ...providerPatch }; + fields.push('externalProvider'); + } + const vae = getMetadataVae(metadata, values.model, vaeModels); if (vae !== undefined) { diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index c9db763eea2..a419e62ff81 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -248,6 +248,14 @@ const createGenerateValues = (overrides: Partial = {}): Ge ideogram4GuidanceScale: null, ideogram4Mu: null, ideogram4ColorPalette: [], + externalImageSize: null, + openaiQuality: 'auto', + openaiBackground: 'auto', + openaiInputFidelity: null, + geminiTemperature: null, + geminiThinkingLevel: null, + seedreamWatermark: false, + seedreamOptimizePrompt: false, krea2RebalanceEnabled: false, krea2RebalanceMultiplier: 4, krea2RebalanceWeights: '1.0,1.0,1.0,1.0,1.0,1.0,1.0,2.5,5.0,1.1,4.0,1.0', From 5a3ccc8cea587526a057e34e961d36e312736167 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 11 Oct 2026 02:33:15 -0600 Subject: [PATCH 2/4] fix(generation): keep hosted-provider sizes, edits, and canvas inputs consistent Starter overlays fill only the capability, default-settings, and panel-schema fields a record never set, so saved external model edits persist through responses and generation; the router and service share one helper. Canvas asks a provider for the bounding box itself and hides render size and denoising strength for it; control layers and regional guidance are rejected by name. A named provider size is sent only when the current dimensions match its preset; the provider id is a select over the registered providers, falling back to text while the list is unavailable. --- .../Users Guide/03.Models/01.introduction.mdx | 8 +- .../03.Models/External Models/openai.mdx | 2 +- .../03.Models/External Models/seedream.mdx | 2 +- invokeai/app/api/routers/model_manager.py | 20 +---- .../external_generation_default.py | 19 +---- .../external_generation/starter_overrides.py | 30 ++++++++ .../frontend/webv2/public/locales/en.json | 1 - .../webv2/scripts/mock-backend-fixtures.mjs | 5 +- .../core/canvas/canvasProcessingSize.test.ts | 13 ++++ .../core/canvas/canvasProcessingSize.ts | 6 +- .../core/canvas/compileCanvasGraph.test.ts | 52 +++++++++++++ .../core/canvas/compileCanvasGraph.ts | 29 ++++--- .../generation/core/externalProvider.test.ts | 13 +++- .../generation/core/externalProvider.ts | 19 +++-- .../features/generation/core/graph.test.ts | 10 +++ .../GenerateDimensionFields.browser.test.tsx | 49 ++++++++++-- .../generation/ui/GenerateDimensionFields.tsx | 3 +- ...ateProviderOptionsSection.browser.test.tsx | 13 ++-- .../ui/GenerateRenderSection.browser.test.tsx | 20 ++++- .../generation/ui/GenerateRenderSection.tsx | 5 +- .../ui/shared/ExternalSizeSelect.tsx | 18 +++-- .../externalModelFields.test.ts | 16 ++-- .../detail => core}/externalModelFields.ts | 12 +-- .../ExternalModelSection.browser.test.tsx | 49 +++++++++++- .../models/ui/detail/ExternalModelSection.tsx | 76 ++++++++++++++++--- .../features/models/ui/detail/ModelDetail.tsx | 5 +- .../features/models/ui/modelDraftsStore.ts | 2 +- tests/app/routers/test_model_manager.py | 48 ++++++++++++ .../test_external_generation_service.py | 49 ++++++++++++ 29 files changed, 472 insertions(+), 122 deletions(-) create mode 100644 invokeai/app/services/external_generation/starter_overrides.py rename invokeai/frontend/webv2/src/features/models/{ui/detail => core}/externalModelFields.test.ts (92%) rename invokeai/frontend/webv2/src/features/models/{ui/detail => core}/externalModelFields.ts (91%) diff --git a/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx b/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx index a61e2869594..caa505307cf 100644 --- a/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx @@ -99,12 +99,16 @@ been provided, the list of models hosted by that service will appear as selections in the Image Generation panel. Opening an external model in the Model Manager shows an **External -Provider** section where you can change the provider and provider model -ID it routes to, the modes (text to image, image to image, inpaint) and +Provider** section where you can change the provider (one of the +registered providers) and provider model ID it routes to, the modes +(text to image, image to image, inpaint) and inputs (seed, reference images, negative prompt) it advertises, its request limits, and the size and image count the provider is asked for by default. The Generate panel and the Canvas only offer what the record advertises, so keep it in step with the provider's documentation. +A starter model's record follows the shipped definition until you save +an edit to its capabilities or defaults; from then on the record's own +values are used. :::note[Gated Downloads] The API Keys section is also used to provide keys for services that diff --git a/docs/src/content/docs/Users Guide/03.Models/External Models/openai.mdx b/docs/src/content/docs/Users Guide/03.Models/External Models/openai.mdx index adbb53c771c..9e20bb9c052 100644 --- a/docs/src/content/docs/Users Guide/03.Models/External Models/openai.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/External Models/openai.mdx @@ -56,7 +56,7 @@ The **Size** section lists the provider's fixed sizes per aspect ratio (**Provid ## Canvas -GPT Image models that advertise `img2img` can generate from the Canvas: an empty bounding box runs text-to-image, and a filled one sends the composited bounding box as the init image. Inpainting and outpainting are not available for OpenAI models. +GPT Image models that advertise `img2img` can generate from the Canvas: an empty bounding box runs text-to-image, and a filled one sends the composited bounding box as the init image. The provider edits the composite whole at the bounding box's own size, so the render size, denoising strength, control layers and regional guidance do not apply and are not offered. Inpainting and outpainting are not available for OpenAI models. ## How Requests Are Routed diff --git a/docs/src/content/docs/Users Guide/03.Models/External Models/seedream.mdx b/docs/src/content/docs/Users Guide/03.Models/External Models/seedream.mdx index efd57170120..2ce2095d2f7 100644 --- a/docs/src/content/docs/Users Guide/03.Models/External Models/seedream.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/External Models/seedream.mdx @@ -57,7 +57,7 @@ Both are recorded in the image metadata and restored by **Recall All** and **Rem ## Canvas -Seedream models advertise `img2img`, so they can generate from the Canvas: an empty bounding box runs text-to-image, and a filled one sends the composited bounding box as the init image. Inpainting and outpainting are not available. +Seedream models advertise `img2img`, so they can generate from the Canvas: an empty bounding box runs text-to-image, and a filled one sends the composited bounding box as the init image. The provider edits the composite whole at the bounding box's own size, so the render size, denoising strength, control layers and regional guidance do not apply and are not offered. Inpainting and outpainting are not available. **Seed** and **guidance scale** are not accepted by the 4.x / 5.x family. diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index 2cc77dfd5f8..e6a00736fad 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -25,6 +25,7 @@ from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.services.external_generation.starter_overrides import apply_external_starter_overrides from invokeai.app.services.model_images.model_images_common import ( ModelImageFileDeleteException, ModelImageFileNotFoundException, @@ -188,26 +189,11 @@ def add_cover_image_to_model_config(config: AnyModelConfig, dependencies: Type[A def apply_external_starter_model_overrides(config: AnyModelConfig) -> AnyModelConfig: - """Overlay starter-model metadata onto installed external model configs.""" + """Fill an installed external model config's unset fields from its starter definition.""" if not isinstance(config, ExternalApiModelConfig): return config - starter_match = next((starter for starter in STARTER_MODELS if starter.source == config.source), None) - if starter_match is None: - return config - - model_updates: dict[str, object] = {} - if starter_match.capabilities is not None: - model_updates["capabilities"] = starter_match.capabilities - if starter_match.default_settings is not None: - model_updates["default_settings"] = starter_match.default_settings - if starter_match.panel_schema is not None: - model_updates["panel_schema"] = starter_match.panel_schema - - if not model_updates: - return config - - return config.model_copy(update=model_updates) + return apply_external_starter_overrides(config) def prepare_model_config_for_response(config: AnyModelConfig, dependencies: Type[ApiDependencies]) -> AnyModelConfig: diff --git a/invokeai/app/services/external_generation/external_generation_default.py b/invokeai/app/services/external_generation/external_generation_default.py index 506c0020fdc..1e1305a2509 100644 --- a/invokeai/app/services/external_generation/external_generation_default.py +++ b/invokeai/app/services/external_generation/external_generation_default.py @@ -26,8 +26,8 @@ ExternalGenerationResult, ExternalProviderStatus, ) +from invokeai.app.services.external_generation.starter_overrides import apply_external_starter_overrides from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalImageSize -from invokeai.backend.model_manager.starter_models import STARTER_MODELS if TYPE_CHECKING: from invokeai.app.services.model_records import ModelRecordServiceBase @@ -188,7 +188,7 @@ def _refresh_model_capabilities(self, request: ExternalGenerationRequest) -> Ext if record.provider_model_id != request.model.provider_model_id: return request - record = _apply_starter_overrides(record) + record = apply_external_starter_overrides(record) if record == request.model: return request @@ -402,18 +402,3 @@ def _resize_result_images(result: ExternalGenerationResult, width: int, height: provider_metadata=result.provider_metadata, content_filters=result.content_filters, ) - - -def _apply_starter_overrides(model: ExternalApiModelConfig) -> ExternalApiModelConfig: - source = model.source or f"external://{model.provider_id}/{model.provider_model_id}" - starter_match = next((starter for starter in STARTER_MODELS if starter.source == source), None) - if starter_match is None: - return model - updates: dict[str, object] = {} - if starter_match.capabilities is not None: - updates["capabilities"] = starter_match.capabilities - if starter_match.default_settings is not None: - updates["default_settings"] = starter_match.default_settings - if not updates: - return model - return model.model_copy(update=updates) diff --git a/invokeai/app/services/external_generation/starter_overrides.py b/invokeai/app/services/external_generation/starter_overrides.py new file mode 100644 index 00000000000..b1a3ddac67f --- /dev/null +++ b/invokeai/app/services/external_generation/starter_overrides.py @@ -0,0 +1,30 @@ +from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities +from invokeai.backend.model_manager.starter_models import STARTER_MODELS + +_UNSET_CAPABILITIES = ExternalModelCapabilities() + + +def apply_external_starter_overrides(config: ExternalApiModelConfig) -> ExternalApiModelConfig: + """Fill a starter-sourced record's unset fields from its starter definition. + + A record that never set a field (the default capability matrix, no default settings, no panel schema) + follows the shipped starter definition, so definition updates reach it. A field the record sets, by an + install that copied the definition or by an edit in the model editor, is the record's own and wins. + """ + source = config.source or f"external://{config.provider_id}/{config.provider_model_id}" + starter_match = next((starter for starter in STARTER_MODELS if starter.source == source), None) + if starter_match is None: + return config + + updates: dict[str, object] = {} + if starter_match.capabilities is not None and config.capabilities == _UNSET_CAPABILITIES: + updates["capabilities"] = starter_match.capabilities + if starter_match.default_settings is not None and config.default_settings is None: + updates["default_settings"] = starter_match.default_settings + if starter_match.panel_schema is not None and config.panel_schema is None: + updates["panel_schema"] = starter_match.panel_schema + + if not updates: + return config + + return config.model_copy(update=updates) diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 45c2750e3a5..9346ae161b1 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -1343,7 +1343,6 @@ "saveExternalSettings": "Save Provider Settings", "externalSettingsSaved": "Provider settings saved", "failedToSaveExternalSettings": "Failed to save provider settings.", - "invalidExternalSettings": "Invalid provider settings.", "externalModes": { "txt2img": "Text to image", "img2img": "Image to image", diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs index 800e354d78d..d5b3cdb2a6f 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs @@ -399,6 +399,7 @@ const MODEL_TYPES = ['main', 'lora', 'controlnet', 'vae', 'embedding']; /** * Hosted-provider records with the capability shapes the Generate panel, Canvas, and Model Manager read: a named * resolution table (Gemini), fixed sizes per ratio with an edit mode (OpenAI), and provider toggles (Seedream). + * No shipped provider accepts a mask, so none advertises inpaint. */ const EXTERNAL_MODEL_FIXTURES = [ { @@ -457,12 +458,12 @@ const EXTERNAL_MODEL_FIXTURES = [ '2:3': { height: 1536, width: 1024 }, }, max_images_per_request: 10, - modes: ['txt2img', 'img2img', 'inpaint'], + modes: ['txt2img', 'img2img'], supports_reference_images: true, }, cover_image: null, default_settings: { height: 1024, num_images: 1, width: 1024 }, - description: 'Fixture hosted model with fixed ratio sizes and canvas edit modes.', + description: 'Fixture hosted model with fixed ratio sizes and canvas image-to-image.', file_size: 0, format: 'external_api', hash: 'external:openai:fixture-gpt-image', diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/canvasProcessingSize.test.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/canvasProcessingSize.test.ts index b29d7bf78b3..37326ca3cee 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/canvasProcessingSize.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/canvasProcessingSize.test.ts @@ -9,8 +9,21 @@ seedArchitectureCapabilities(); const sd1 = { base: 'sd-1', type: 'main' } as GenerateModelConfig; const sdxl = { base: 'sdxl', type: 'main' } as GenerateModelConfig; +const external = { base: 'external', type: 'external_image_generator' } as GenerateModelConfig; describe('resolveCanvasProcessingSize', () => { + it('asks a hosted provider for the bbox itself, whatever the scaling method', () => { + const bbox = { height: 300, width: 500 }; + + expect(resolveCanvasProcessingSize(external, 'off', bbox, undefined)).toEqual(bbox); + expect(resolveCanvasProcessingSize(external, 'off', bbox, { height: null, method: 'auto', width: null })).toEqual( + bbox + ); + expect(resolveCanvasProcessingSize(external, 'off', bbox, { height: 2048, method: 'manual', width: 2048 })).toEqual( + bbox + ); + }); + it('keeps the bbox on the model grid when scaling is off or unset', () => { expect(resolveCanvasProcessingSize(sd1, 'off', { height: 300, width: 500 }, undefined)).toEqual({ height: 304, diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/canvasProcessingSize.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/canvasProcessingSize.ts index a3ea9431b43..b3976bc406b 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/canvasProcessingSize.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/canvasProcessingSize.ts @@ -1,6 +1,7 @@ /** * None snaps the bbox to the model grid; auto grows undersized content toward optimal area at its aspect ratio; - * manual uses the selected size. Restore final output to the exact bbox. + * manual uses the selected size. Restore final output to the exact bbox. Hosted providers are asked for the bbox + * itself: they bucket the request to their own size table and return one image, so there is nothing to scale. */ import type { GenerateModelConfig, PidMode } from '@features/generation/core/types'; @@ -80,6 +81,9 @@ export const resolveCanvasProcessingSize = ( bbox: CanvasSize, scaling: CanvasScalingSettings | undefined ): CanvasSize => { + if (model.type === 'external_image_generator') { + return { height: bbox.height, width: bbox.width }; + } const { grid, optimal } = getGenerationDimensions(model, pidMode); switch (scaling?.method) { case 'auto': { diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts index 80610f788cd..ea9ba34ef0a 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts @@ -493,6 +493,58 @@ describe('compileCanvasGraph', () => { 'OpenAI Image does not support outpainting on the canvas.' ); }); + + it('rejects control layers and regional guidance by name instead of failing inside a graft', () => { + const layer: ControlLayerGraphInput = { + beginEndStepPct: [0, 1], + controlMode: 'balanced', + id: 'control', + imageName: 'control.png', + kind: 'controlnet', + model: { base: 'sd-1', key: 'canny', name: 'Canny', type: 'controlnet' }, + weight: 1, + }; + + expect(() => compile(editModel, 'txt2img', { compositeImageName: null, controlLayers: [layer] })).toThrow( + 'OpenAI Image does not support control layers.' + ); + expect(() => + compile(editModel, 'txt2img', { + compositeImageName: null, + regionalGuidance: [ + { + autoNegative: false, + id: 'r1', + maskImageName: 'r1-mask.png', + negativePrompt: null, + positivePrompt: 'a cat', + referenceImages: [], + }, + ], + }) + ).toThrow('OpenAI Image does not support regional guidance.'); + }); + + it('ignores the denoising strength: the provider edits at its own, so none is validated, sent or recorded', () => { + const { backendGraph } = compile(editModel, 'img2img', { compositeImageName: 'bbox.png', strength: 0 }); + const metadata = getNodeByType(backendGraph, 'core_metadata'); + + expect(backendGraph.nodes.canvas_output).not.toHaveProperty('strength'); + expect(backendGraph.nodes.canvas_output).not.toHaveProperty('denoising_start'); + expect(metadata).not.toHaveProperty('strength'); + expect(metadata).not.toHaveProperty('generation_mode'); + }); + + it('asks for the bbox itself even when the render size is set to auto or a custom size', () => { + const custom = compile(editModel, 'img2img', { + bbox: smallBbox, + compositeImageName: 'bbox.png', + scaling: { height: 2048, method: 'manual', width: 2048 }, + }); + + expect(custom.backendGraph.nodes.canvas_output).toMatchObject({ height: 512, width: 640 }); + expect(Object.values(custom.backendGraph.nodes).map((node) => node.type)).not.toContain('img_resize'); + }); }); describe('unsupported models', () => { diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts index 3ef293b735e..7d86c2dd3bc 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts @@ -63,10 +63,19 @@ const getCanvasValidationReasons = (input: CompileCanvasGraphInput): string[] => const { bbox, compositeImageName, maskImageName, mode, model, strength } = input; const reasons: string[] = []; - // Providers take a whole image to edit; a partially covered bbox has nothing to send for the empty area. The - // shared validator reports the other modes the model does not advertise. - if (model.type === 'external_image_generator' && mode === 'outpaint') { - reasons.push(`${model.name} does not support outpainting on the canvas.`); + if (model.type === 'external_image_generator') { + // A provider takes a whole image to edit and nothing else: a partially covered bbox has nothing to send for the + // empty area, and the request carries no conditioning beyond the prompt, the init image and the mask. The + // shared validator reports the other modes the model does not advertise. + if (mode === 'outpaint') { + reasons.push(`${model.name} does not support outpainting on the canvas.`); + } + if (input.controlLayers && input.controlLayers.length > 0) { + reasons.push(`${model.name} does not support control layers.`); + } + if (input.regionalGuidance && input.regionalGuidance.length > 0) { + reasons.push(`${model.name} does not support regional guidance.`); + } } // Reject canvas PiD because its graph lacks the expected VAE/decode seam. @@ -85,7 +94,8 @@ const getCanvasValidationReasons = (input: CompileCanvasGraphInput): string[] => reasons.push('Canvas generation requires a composited source image.'); } - if (!Number.isFinite(strength) || strength <= 0 || strength > 1) { + // A provider edits at its own strength; only the local denoise reads this one. + if (model.type !== 'external_image_generator' && (!Number.isFinite(strength) || strength <= 0 || strength > 1)) { reasons.push('Canvas denoising strength must be greater than 0 and at most 1.'); } } @@ -610,11 +620,7 @@ const graftOutpaint = ( /** Throw the first validation reason. */ export const compileCanvasGraph = (input: CompileCanvasGraphInput): CompiledCanvasGraph => { const { bbox, compositeImageName, destination, mode, model, projectSettings, strength } = input; - // A provider buckets the request to its own size table and returns one image, so it is asked for the bbox itself. - const processingSize = - model.type === 'external_image_generator' - ? { height: bbox.height, width: bbox.width } - : resolveCanvasProcessingSize(model, input.settings.pidMode, bbox, input.scaling); + const processingSize = resolveCanvasProcessingSize(model, input.settings.pidMode, bbox, input.scaling); const settings = withProcessingDimensions(input.settings, processingSize); const validationReasons = [ @@ -639,7 +645,8 @@ export const compileCanvasGraph = (input: CompileCanvasGraphInput): CompiledCanv } // Validation above guarantees the composite for the edit modes and the mask for inpaint. The provider returns - // the edited image whole, so no encode, mask tail, control, or regional grafts apply. + // the edited image whole, so no encode, mask tail, control, or regional grafts apply, and no denoising strength + // is sent or recorded. Metadata carries no `generation_mode`: the backend's mode list has no provider variants. const initImageName = compositeImageName as string; const backendGraph = buildExternalGraph( settings, diff --git a/invokeai/frontend/webv2/src/features/generation/core/externalProvider.test.ts b/invokeai/frontend/webv2/src/features/generation/core/externalProvider.test.ts index e23290caf76..5c1d627f2cd 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/externalProvider.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/externalProvider.test.ts @@ -56,13 +56,18 @@ describe('external provider options', () => { ).toContain('openaiInputFidelity'); }); - it('resolves a named size only from the selected model’s own presets', () => { + it('resolves a named size only from the selected model’s own presets, at the preset’s exact size', () => { const gemini = external('gemini', { modes: ['txt2img'], resolution_presets: presets }); - expect(resolveExternalImageSize({ externalImageSize: '2K' }, gemini)).toBe('2K'); - expect(resolveExternalImageSize({ externalImageSize: '4K' }, gemini)).toBeNull(); + expect(resolveExternalImageSize({ externalImageSize: '2K', height: 1152, width: 2048 }, gemini)).toBe('2K'); + expect(resolveExternalImageSize({ externalImageSize: '4K', height: 1152, width: 2048 }, gemini)).toBeNull(); + // A size typed by hand after picking a preset is custom: the provider buckets the dimensions instead. + expect(resolveExternalImageSize({ externalImageSize: '2K', height: 1152, width: 2000 }, gemini)).toBeNull(); expect( - resolveExternalImageSize({ externalImageSize: '2K' }, external('openai', { modes: ['txt2img'] })) + resolveExternalImageSize( + { externalImageSize: '2K', height: 1152, width: 2048 }, + external('openai', { modes: ['txt2img'] }) + ) ).toBeNull(); }); diff --git a/invokeai/frontend/webv2/src/features/generation/core/externalProvider.ts b/invokeai/frontend/webv2/src/features/generation/core/externalProvider.ts index 84f81fce724..6951bfc77a4 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/externalProvider.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/externalProvider.ts @@ -97,16 +97,6 @@ export const getExternalAspectRatioSizes = ( return sizes ? Object.entries(sizes) : []; }; -/** The named size to send: only one the model lists, so a stale pick from another model never reaches the provider. */ -export const resolveExternalImageSize = ( - settings: Pick, - model: GenerateModelConfig | undefined -): string | null => - settings.externalImageSize !== null && - getExternalResolutionPresets(model).some((preset) => preset.image_size === settings.externalImageSize) - ? settings.externalImageSize - : null; - /** The preset matching the current size and named size exactly, for the preset select's value. */ export const findExternalResolutionPreset = ( settings: Pick, @@ -119,6 +109,15 @@ export const findExternalResolutionPreset = ( preset.height === settings.height ) ?? null; +/** + * The named size to send: the selected model's preset the current size matches exactly, so a hand-typed size or a + * pick left over from another model never overrides the dimensions at the provider. + */ +export const resolveExternalImageSize = ( + settings: Pick, + model: GenerateModelConfig | undefined +): string | null => findExternalResolutionPreset(settings, model)?.image_size ?? null; + /** * Provider-specific invocation fields for the selected model, keyed as the node declares them. Optional fields are * omitted when unset so the node applies its own default. diff --git a/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts b/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts index 04e1853ba11..70f141f1c71 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts @@ -704,6 +704,16 @@ describe('compileGenerateGraph', () => { expect(unlisted.nodes.canvas_output?.image_size).toBeNull(); expect(getNodeByType(unlisted, 'core_metadata')).not.toHaveProperty('image_size'); + + // Dimensions typed after the pick are custom: the named size would otherwise override them at the provider. + const typed = compile(providerModel('gemini', { modes: ['txt2img'], resolution_presets: presets }), { + ...providerSettings, + height: 2048, + width: 1536, + }); + + expect(typed.nodes.canvas_output).toMatchObject({ height: 2048, image_size: null, width: 1536 }); + expect(getNodeByType(typed, 'core_metadata')).not.toHaveProperty('image_size'); }); it('accepts provider sizes that are not multiples of a local model grid', () => { diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.browser.test.tsx index d8fd6865b3f..deb161a88a9 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.browser.test.tsx @@ -1,5 +1,5 @@ /* oxlint-disable react-perf/jsx-no-new-object-as-prop */ -import type { GenerateSettings, MainModelConfig } from '@features/generation/core/types'; +import type { GenerateModelConfig, GenerateSettings, MainModelConfig } from '@features/generation/core/types'; import { ChakraProvider } from '@chakra-ui/react'; import { getDefaultGenerateSettings } from '@features/generation/core/baseGenerationPolicies'; @@ -11,13 +11,23 @@ import { createRoot, type Root } from 'react-dom/client'; import { I18nextProvider, initReactI18next } from 'react-i18next'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CanvasRenderSizeProps } from './GenerationUiContext'; + import { GenerateDimensionFields } from './GenerateDimensionFields'; +const harness = vi.hoisted(() => ({ invocationSourceId: 'generate' })); + vi.mock('./GenerationUiContext', () => ({ useGenerationQueueInsights: (select: (insights: unknown) => unknown) => select({ secondsPerRun: null, seedHistory: [] }), useGenerationUi: () => ({ - project: { invocationSourceId: 'generate' }, + // Stands in for the canvas's render-size slot, which lays out the Size section around its controls. + CanvasRenderSize: ({ children, frame }: CanvasRenderSizeProps) => + children({ + controls:
, + size: { height: frame.height * 2, width: frame.width * 2 }, + }), + project: { invocationSourceId: harness.invocationSourceId }, sectionPreferences: { sectionsOpen: { dimensions: true }, setSectionOpen: vi.fn() }, }), })); @@ -49,6 +59,15 @@ void i18n.use(initReactI18next).init({ }); const sd1Model: MainModelConfig = { base: 'sd-1', key: 'sd1', name: 'SD 1.5', type: 'main' }; +const gptImage: GenerateModelConfig = { + base: 'external', + capabilities: { modes: ['txt2img', 'img2img'] }, + format: 'external_api', + key: 'gpt-image', + name: 'GPT Image', + provider_id: 'openai', + type: 'external_image_generator', +}; let host: HTMLDivElement | null = null; let root: Root | null = null; @@ -62,7 +81,7 @@ const settle = (action: () => void): Promise => }); }); -const render = async (settings: Partial) => { +const render = async (settings: Partial, model: GenerateModelConfig = sd1Model) => { host = document.createElement('div'); document.body.append(host); root = createRoot(host); @@ -73,9 +92,9 @@ const render = async (settings: Partial) => { @@ -94,12 +113,32 @@ const bracket = () => host?.querySelector('[data-part="bracket"]')?.parentElemen const presetTrigger = () => host?.querySelector('[role="combobox"]') ?? null; afterEach(async () => { + harness.invocationSourceId = 'generate'; await settle(() => root?.unmount()); host?.remove(); host = null; root = null; }); +describe('GenerateDimensionFields canvas render size', () => { + it('lays out the render-size controls and the resized badge for a local model on the canvas', async () => { + harness.invocationSourceId = 'canvas'; + await render({ height: 512, width: 512 }); + + expect(host?.querySelector('[data-testid="render-size-controls"]')).not.toBeNull(); + expect(host?.textContent).toContain('512x512 → 1024x1024'); + }); + + it('shows a hosted provider only the frame, which is the size the provider is asked for', async () => { + harness.invocationSourceId = 'canvas'; + await render({ height: 512, width: 512 }, gptImage); + + expect(host?.querySelector('[data-testid="render-size-controls"]')).toBeNull(); + expect(host?.textContent).not.toContain('→'); + expect(host?.textContent).toContain('512x512'); + }); +}); + describe('GenerateDimensionFields aspect ratio', () => { it('reshapes the size to a chosen preset and locks it', async () => { const onCommit = await render({ aspectRatioId: 'Free', aspectRatioIsLocked: false, height: 1024, width: 1024 }); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx index 53f0e66bc65..477afd1f0e5 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx @@ -583,7 +583,8 @@ export const GenerateDimensionFields = ({ }; // Switching source remounts the section's subtree; that is deliberate, as its state lives in this component. - return project.invocationSourceId === 'canvas' ? ( + // A hosted provider is asked for the frame itself (it buckets to its own size table), so there is no render size. + return project.invocationSourceId === 'canvas' && selectedModel?.type !== 'external_image_generator' ? ( {renderSection} ) : ( renderSection(null) diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateProviderOptionsSection.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateProviderOptionsSection.browser.test.tsx index 0062617452d..89da813f69a 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateProviderOptionsSection.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateProviderOptionsSection.browser.test.tsx @@ -84,17 +84,15 @@ const render = async (model: GenerateModelConfig, settings: Partial) => draft.setSnapshot({ ...draft.getSnapshot(), ...patch })); await settle(() => { root?.render( - + ); @@ -157,10 +155,11 @@ describe('GenerateProviderOptionsSection', () => { expect(selectNames()).toEqual(['Thinking level']); // Temperature starts at the provider default: no slider until overridden. - expect(host?.querySelector('input[aria-label="Temperature"]')).toBeNull(); + expect(host?.querySelector('[role="slider"]')).toBeNull(); await settle(() => switches()[0]?.click()); expect(onCommit).toHaveBeenCalledWith({ geminiTemperature: 1 }); + expect(host?.querySelector('[role="slider"]')?.getAttribute('aria-valuenow')).toBe('1'); await settle(() => selectTriggers()[0]?.click()); await settle(() => option('Provider default')?.click()); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.browser.test.tsx index 7b97d4bf1ce..bc627e0c564 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.browser.test.tsx @@ -1,5 +1,5 @@ /* oxlint-disable react-perf/jsx-no-new-object-as-prop */ -import type { GenerateSettings, MainModelConfig } from '@features/generation/core/types'; +import type { GenerateModelConfig, GenerateSettings, MainModelConfig } from '@features/generation/core/types'; import { ChakraProvider } from '@chakra-ui/react'; import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; @@ -141,7 +141,7 @@ const settle = (action: () => void): Promise => }); }); -const render = async (model: MainModelConfig, settings: Partial = {}) => { +const render = async (model: GenerateModelConfig, settings: Partial = {}) => { host = document.createElement('div'); document.body.append(host); root = createRoot(host); @@ -286,6 +286,22 @@ describe('GenerateRenderSection denoising strength', () => { expect(guidanceIndex).toBeGreaterThan(-1); expect(controls[guidanceIndex + 1]?.getAttribute('data-testid')).toBe('strength-field'); }); + + it('adds no strength for a hosted provider, which edits the canvas composite at its own', async () => { + harness.invocationSourceId = 'canvas'; + await render({ + base: 'external', + capabilities: { modes: ['txt2img', 'img2img'] }, + format: 'external_api', + key: 'gpt-image', + name: 'GPT Image', + provider_id: 'openai', + type: 'external_image_generator', + }); + + expect(host?.querySelector('[data-testid="strength-field"]')).toBeNull(); + expect(host?.querySelector('[data-testid="strength-badge"]')).toBeNull(); + }); }); /** Resolve names through both aria-labelledby and aria-label. */ diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.tsx index f565e7efb80..9e5d9645345 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.tsx @@ -472,8 +472,9 @@ export const GenerateRenderSection = ({ ); - // Switching source remounts the section's subtree, as the Size section does. - return project.invocationSourceId === 'canvas' ? ( + // Switching source remounts the section's subtree, as the Size section does. A hosted provider edits the canvas + // composite at its own strength, so the canvas adds none for it. + return project.invocationSourceId === 'canvas' && selectedModel?.type !== 'external_image_generator' ? ( {renderSection} ) : ( renderSection(null) diff --git a/invokeai/frontend/webv2/src/features/generation/ui/shared/ExternalSizeSelect.tsx b/invokeai/frontend/webv2/src/features/generation/ui/shared/ExternalSizeSelect.tsx index ce62b7caf6a..13b541013c8 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/shared/ExternalSizeSelect.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/shared/ExternalSizeSelect.tsx @@ -39,18 +39,22 @@ const sizePatch = (ratio: string, width: number, height: number): Partial { const { t } = useTranslation(); - const presets = getExternalResolutionPresets(model); - const ratioSizes = getExternalAspectRatioSizes(model); - const collection = useMemo( - () => - createListCollection({ + // One table per model: the policy reads fresh arrays off the record, so the record is the memo key. + const { collection, presets, ratioSizes } = useMemo(() => { + const presets = getExternalResolutionPresets(model); + const ratioSizes = getExternalAspectRatioSizes(model); + + return { + collection: createListCollection({ items: presets.length > 0 ? presets.map((preset) => ({ label: preset.label, value: presetKey(preset) })) : ratioSizes.map(([ratio, size]) => ({ label: `${ratio} (${size.width}×${size.height})`, value: ratio })), }), - [presets, ratioSizes] - ); + presets, + ratioSizes, + }; + }, [model]); if (presets.length === 0 && ratioSizes.length === 0) { return null; diff --git a/invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.test.ts b/invokeai/frontend/webv2/src/features/models/core/externalModelFields.test.ts similarity index 92% rename from invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.test.ts rename to invokeai/frontend/webv2/src/features/models/core/externalModelFields.test.ts index e7f239db5ea..22b4a53ea48 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.test.ts +++ b/invokeai/frontend/webv2/src/features/models/core/externalModelFields.test.ts @@ -1,13 +1,9 @@ -import type { TFunction } from 'i18next'; - import { describe, expect, it } from 'vitest'; import type { ExternalModelSettingsModel } from './externalModelFields'; import { toExternalModelChanges, toExternalModelValues, validateExternalModel } from './externalModelFields'; -const t = ((key: string) => key) as unknown as TFunction; - const geminiModel: ExternalModelSettingsModel = { capabilities: { allowed_aspect_ratios: ['1:1', '16:9'], @@ -106,15 +102,15 @@ describe('external model record mapping', () => { it('rejects an empty route, no modes, half a max size, and non-positive limits', () => { const values = toExternalModelValues(geminiModel); - expect(validateExternalModel(values, t)).toBeNull(); - expect(validateExternalModel({ ...values, providerModelId: ' ' }, t)).toBe('Provider model ID is required.'); - expect(validateExternalModel({ ...values, modeTxt2img: false }, t)).toBe('Choose at least one supported mode.'); - expect(validateExternalModel({ ...values, maxImageHeight: null }, t)).toBe( + expect(validateExternalModel(values)).toBeNull(); + expect(validateExternalModel({ ...values, providerModelId: ' ' })).toBe('Provider model ID is required.'); + expect(validateExternalModel({ ...values, modeTxt2img: false })).toBe('Choose at least one supported mode.'); + expect(validateExternalModel({ ...values, maxImageHeight: null })).toBe( 'Set both a max width and a max height, or neither.' ); - expect(validateExternalModel({ ...values, maxImagesPerRequest: 0 }, t)).toBe( + expect(validateExternalModel({ ...values, maxImagesPerRequest: 0 })).toBe( 'Images per request must be greater than 0.' ); - expect(validateExternalModel({ ...values, defaultWidth: 1024.5 }, t)).toBe('Default width must be a whole number.'); + expect(validateExternalModel({ ...values, defaultWidth: 1024.5 })).toBe('Default width must be a whole number.'); }); }); diff --git a/invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.ts b/invokeai/frontend/webv2/src/features/models/core/externalModelFields.ts similarity index 91% rename from invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.ts rename to invokeai/frontend/webv2/src/features/models/core/externalModelFields.ts index 3dfd3f36986..bf146f074f1 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/detail/externalModelFields.ts +++ b/invokeai/frontend/webv2/src/features/models/core/externalModelFields.ts @@ -1,8 +1,7 @@ -import type { ExternalModelFormValues } from '@features/models/core/schemas'; -import type { ExternalModelCapabilities, ModelConfig, ModelRecordChanges } from '@features/models/core/types'; -import type { TFunction } from 'i18next'; +import type { ExternalModelFormValues } from './schemas'; +import type { ExternalModelCapabilities, ModelConfig, ModelRecordChanges } from './types'; -import { externalModelSchema } from '@features/models/core/schemas'; +import { externalModelSchema } from './schemas'; export type ExternalModelSettingsModel = Pick< ModelConfig, @@ -76,8 +75,9 @@ export const toExternalModelChanges = ( }; }; -export const validateExternalModel = (values: ExternalModelFormValues, t: TFunction): string | null => { +/** The first schema message, like the other record editors' messages, so the UI shows it as is. */ +export const validateExternalModel = (values: ExternalModelFormValues): string | null => { const result = externalModelSchema.safeParse(values); - return result.success ? null : (result.error.issues[0]?.message ?? t('models.invalidExternalSettings')); + return result.success ? null : (result.error.issues[0]?.message ?? 'Invalid provider settings.'); }; diff --git a/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.browser.test.tsx b/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.browser.test.tsx index 372e62adca1..51bc8a8222d 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.browser.test.tsx @@ -1,3 +1,4 @@ +import type { ExternalModelSettingsModel } from '@features/models/core/externalModelFields'; import type * as ModelsApi from '@features/models/data/api'; import { ChakraProvider } from '@chakra-ui/react'; @@ -8,11 +9,15 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ExternalModelSettingsModel } from './externalModelFields'; - import { ExternalModelSection } from './ExternalModelSection'; const api = vi.hoisted(() => ({ + getExternalProviderConfigs: vi.fn(() => + Promise.resolve([ + { api_key_configured: true, base_url: null, provider_id: 'openai' }, + { api_key_configured: false, base_url: null, provider_id: 'gemini' }, + ]) + ), getModelsDir: vi.fn(() => Promise.resolve('')), listMissingModels: vi.fn(() => Promise.resolve([])), listModels: vi.fn(() => Promise.resolve([])), @@ -87,8 +92,19 @@ describe('ExternalModelSection', () => { await Promise.resolve(); }); }; + // The select's menu renders after a frame; the provider list after its fetch settles. + const settle = () => + act( + () => + new Promise((resolve) => { + globalThis.setTimeout(resolve, 50); + }) + ); + // The provider is the section's only select; inside its Field it takes the field's label. + const providerTrigger = () => host.querySelector('[data-scope="select"][data-part="trigger"]'); beforeEach(() => { + api.getExternalProviderConfigs.mockClear(); api.updateModel.mockReset(); onError.mockReset(); onSaved.mockReset(); @@ -173,6 +189,35 @@ describe('ExternalModelSection', () => { expect(checkbox('models.externalModes.txt2img').checked).toBe(true); }); + it('offers the registered providers, keeping an unregistered id of the record selectable', async () => { + await render({ ...gptImage, provider_id: 'custom' }); + await settle(); + + expect(host.querySelector('input[aria-label="models.providerId"]')).toBeNull(); + expect(providerTrigger()?.textContent).toContain('custom'); + await click(providerTrigger()!); + await settle(); + + const options = [...document.querySelectorAll('[role="option"]')]; + expect(options.map((option) => option.textContent)).toEqual(['openai', 'gemini', 'custom']); + await click(options[1]!); + await settle(); + + expect(providerTrigger()?.textContent).toContain('gemini'); + expect(getModelDraft('gpt-image-1')?.external?.fields).toEqual({ providerId: 'gemini' }); + }); + + it('keeps the provider a text field while the provider list is unavailable', async () => { + api.getExternalProviderConfigs.mockRejectedValueOnce(new Error('offline')); + await render(gptImage); + await settle(); + + const providerInput = host.querySelector('input[aria-label="models.providerId"]')!; + expect(providerInput.value).toBe('openai'); + await type(providerInput, 'seedream'); + expect(getModelDraft('gpt-image-1')?.external?.fields).toEqual({ providerId: 'seedream' }); + }); + it('surfaces a failed save and keeps the edits for another attempt', async () => { api.updateModel.mockRejectedValue(new Error('Model not found')); await render(gptImage); diff --git a/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.tsx b/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.tsx index 4e958673d48..93b8ab57c64 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.tsx @@ -1,8 +1,28 @@ /* eslint-disable react-perf/jsx-no-jsx-as-prop, react-perf/jsx-no-new-array-as-prop, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop */ +import type { ExternalModelSettingsModel } from '@features/models/core/externalModelFields'; import type { ExternalModelFormValues } from '@features/models/core/schemas'; -import { Checkbox, Grid, HStack, Input, NumberInput, Stack, Switch, Text } from '@chakra-ui/react'; +import { + Checkbox, + createListCollection, + Grid, + HStack, + Input, + NumberInput, + Stack, + Switch, + Text, +} from '@chakra-ui/react'; +import { + toExternalModelChanges, + toExternalModelValues, + validateExternalModel, +} from '@features/models/core/externalModelFields'; import { updateModel } from '@features/models/data/api'; +import { + ensureExternalProvidersLoaded, + useExternalProvidersSelector, +} from '@features/models/data/externalProvidersStore'; import { replaceModelInStore } from '@features/models/data/modelsStore'; import { applyModelDraftFields, @@ -16,17 +36,16 @@ import { useModelDraft, type ModelDraft, } from '@features/models/ui/modelDraftsStore'; +import { useMountEffect } from '@platform/react/useMountEffect'; import { useScopedAction } from '@platform/react/useScopedAction'; import { assertAccountScopeCurrent } from '@platform/state/accountLifecycle'; import { Button } from '@platform/ui/Button'; import { Field, FieldLabel } from '@platform/ui/Field'; import { Panel } from '@platform/ui/Panel'; +import { Select } from '@platform/ui/Select'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { ExternalModelSettingsModel } from './externalModelFields'; - -import { toExternalModelChanges, toExternalModelValues, validateExternalModel } from './externalModelFields'; import { DraftConflictNotice, UnsavedChangesLabel } from './ModelDraftNotices'; /** @@ -120,10 +139,29 @@ export const ExternalModelSection = ({ const values = useMemo(() => applyModelDraftFields(saved, draft), [draft, saved]); const [error, setError] = useState(null); const { run } = useScopedAction(); + const providerConfigs = useExternalProvidersSelector((snapshot) => snapshot.configs); + // The registered providers are what requests can route to; a record's own id stays offered even when it is not + // among them, so an unknown provider reads as what it is rather than as nothing. + const providerCollection = useMemo( + () => + providerConfigs === null + ? null + : createListCollection({ + items: [...new Set([...providerConfigs.map((config) => config.provider_id), values.providerId])] + .filter((id) => id !== '') + .map((id) => ({ label: id, value: id })), + }), + [providerConfigs, values.providerId] + ); // From the draft, not this instance: a save started before the user navigated away may still be in flight. const isSaving = draft?.submitted !== undefined; const isDirty = hasDraftFields(draft); + useMountEffect(() => { + // The list is a convenience: while it is unavailable the provider stays a text field. + ensureExternalProvidersLoaded().catch(() => {}); + }); + const setField = (key: Key, value: ExternalModelFormValues[Key]) => { recordModelDraftFields(model.key, 'external', { ...values, [key]: value }, saved); setError(null); @@ -135,7 +173,7 @@ export const ExternalModelSection = ({ }; const handleSave = async () => { - const validationError = validateExternalModel(values, t); + const validationError = validateExternalModel(values); if (validationError) { setError(validationError); @@ -206,12 +244,28 @@ export const ExternalModelSection = ({ {hasModelDraftConflict(saved, draft) ? : null} - setField('providerId', event.currentTarget.value)} - /> + {providerCollection ? ( + setField('providerId', event.currentTarget.value)} + /> + )} None: + """A capability table or default settings the record sets are returned as stored, not the starter's.""" + config = ExternalApiModelConfig( + key="external_gemini_edited", + name="Gemini Edited", + provider_id="gemini", + provider_model_id="gemini-3.1-flash-image-preview", + capabilities=ExternalModelCapabilities(modes=["txt2img"]), + source="external://gemini/gemini-3.1-flash-image-preview", + ) + mm2_model_manager.store.add_model(config) + + services = type("Services", (), {})() + services.model_manager = mm2_model_manager + services.model_images = DummyModelImages() + services.configuration = mm2_app_config + services.logger = logging.getLogger("test") + + invoker = DummyInvoker(services) + monkeypatch.setattr("invokeai.app.api.routers.model_manager.ApiDependencies", MockApiDependencies(invoker)) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(invoker)) + + edited_capabilities = {"modes": ["txt2img", "img2img"], "max_reference_images": 3, "supports_seed": True} + edited_defaults = {"width": 2048, "height": 2048, "num_images": 1} + patch_response = client.patch( + "/api/v2/models/i/external_gemini_edited", + json={"capabilities": edited_capabilities, "default_settings": edited_defaults}, + ) + + assert patch_response.status_code == 200 + patched = patch_response.json() + assert patched["capabilities"]["modes"] == ["txt2img", "img2img"] + assert patched["capabilities"]["max_reference_images"] == 3 + assert patched["capabilities"]["allowed_aspect_ratios"] is None + assert patched["default_settings"] == edited_defaults + + get_response = client.get("/api/v2/models/i/external_gemini_edited") + + assert get_response.status_code == 200 + fetched = get_response.json() + assert fetched["capabilities"] == patched["capabilities"] + assert fetched["default_settings"] == edited_defaults + # Fields the record never set still follow the starter definition. + assert [control["name"] for control in fetched["panel_schema"]["prompts"]] == ["reference_images"] + + def _make_stats_services(ram_caches: dict) -> Any: class _Load: pass diff --git a/tests/app/services/external_generation/test_external_generation_service.py b/tests/app/services/external_generation/test_external_generation_service.py index 2b6ef3b6ca5..ea181a9582d 100644 --- a/tests/app/services/external_generation/test_external_generation_service.py +++ b/tests/app/services/external_generation/test_external_generation_service.py @@ -228,6 +228,55 @@ def test_generate_happy_path() -> None: assert provider.last_request == request +class _RecordStore: + def __init__(self, record: ExternalApiModelConfig) -> None: + self._record = record + + def get_model(self, key: str) -> ExternalApiModelConfig: + assert key == self._record.key + return self._record + + +def _gemini_record(capabilities: ExternalModelCapabilities) -> ExternalApiModelConfig: + return ExternalApiModelConfig( + key="gemini_flash", + name="Gemini Flash", + provider_id="gemini", + provider_model_id="gemini-2.5-flash-image", + capabilities=capabilities, + ) + + +def test_generate_fills_an_unset_starter_record_from_its_definition() -> None: + starter = next(model for model in STARTER_MODELS if model.source == "external://gemini/gemini-2.5-flash-image") + record = _gemini_record(ExternalModelCapabilities()) + provider = DummyProvider("gemini", configured=True, result=ExternalGenerationResult(images=[])) + service = ExternalGenerationService( + {"gemini": provider}, logging.getLogger("test"), record_store=_RecordStore(record) + ) + + service.generate(_build_request(model=record)) + + assert provider.last_request is not None + assert provider.last_request.model.capabilities == starter.capabilities + assert provider.last_request.model.default_settings == starter.default_settings + + +def test_generate_honours_a_starter_record_edited_past_its_definition() -> None: + # The shipped definition is txt2img only; the record was edited to allow img2img. + record = _gemini_record(ExternalModelCapabilities(modes=["txt2img", "img2img"], supports_seed=True)) + provider = DummyProvider("gemini", configured=True, result=ExternalGenerationResult(images=[])) + service = ExternalGenerationService( + {"gemini": provider}, logging.getLogger("test"), record_store=_RecordStore(record) + ) + + service.generate(_build_request(model=record, mode="img2img", init_image=_make_image())) + + assert provider.last_request is not None + assert provider.last_request.model.capabilities.modes == ["txt2img", "img2img"] + assert provider.last_request.model.capabilities.allowed_aspect_ratios is None + + def test_generate_resizes_inpaint_result_to_original_init_size() -> None: model = _build_model(ExternalModelCapabilities(modes=["inpaint"])) request = _build_request( From fffc35b19de07675ccae68d50a4365f1a0760b20 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 11 Oct 2026 03:17:33 -0600 Subject: [PATCH 3/4] fix(models): show starter-managed provider records read-only instead of overriding the catalog The backend keeps overlaying the starter catalog, so starter records save only their provider ids and show capabilities, limits and defaults read-only; custom records keep the full editor. Unit tests follow the pixel-exact external grid and the mode-specific canvas rejection. --- .../Users Guide/03.Models/01.introduction.mdx | 6 +- invokeai/app/api/routers/model_manager.py | 20 +- .../external_generation_default.py | 19 +- .../external_generation/starter_overrides.py | 30 --- .../frontend/webv2/public/locales/en.json | 5 +- .../models/core/externalModelFields.test.ts | 42 ++++- .../models/core/externalModelFields.ts | 29 ++- .../ExternalModelSection.browser.test.tsx | 40 ++++ .../models/ui/detail/ExternalModelSection.tsx | 174 ++++++++++++------ .../features/models/ui/detail/ModelDetail.tsx | 1 + .../workbench/widgets/canvas/bboxGrid.test.ts | 6 +- .../invoke/prepareCanvasInvocation.test.ts | 5 +- tests/app/routers/test_model_manager.py | 48 ----- .../test_external_generation_service.py | 49 ----- 14 files changed, 277 insertions(+), 197 deletions(-) delete mode 100644 invokeai/app/services/external_generation/starter_overrides.py diff --git a/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx b/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx index caa505307cf..79f031fe038 100644 --- a/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/01.introduction.mdx @@ -106,9 +106,9 @@ inputs (seed, reference images, negative prompt) it advertises, its request limits, and the size and image count the provider is asked for by default. The Generate panel and the Canvas only offer what the record advertises, so keep it in step with the provider's documentation. -A starter model's record follows the shipped definition until you save -an edit to its capabilities or defaults; from then on the record's own -values are used. +A model installed from the starter catalog keeps the catalog's +capabilities and defaults, so those are shown read-only; its provider +and provider model ID remain editable. :::note[Gated Downloads] The API Keys section is also used to provide keys for services that diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index e6a00736fad..2cc77dfd5f8 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -25,7 +25,6 @@ from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies -from invokeai.app.services.external_generation.starter_overrides import apply_external_starter_overrides from invokeai.app.services.model_images.model_images_common import ( ModelImageFileDeleteException, ModelImageFileNotFoundException, @@ -189,11 +188,26 @@ def add_cover_image_to_model_config(config: AnyModelConfig, dependencies: Type[A def apply_external_starter_model_overrides(config: AnyModelConfig) -> AnyModelConfig: - """Fill an installed external model config's unset fields from its starter definition.""" + """Overlay starter-model metadata onto installed external model configs.""" if not isinstance(config, ExternalApiModelConfig): return config - return apply_external_starter_overrides(config) + starter_match = next((starter for starter in STARTER_MODELS if starter.source == config.source), None) + if starter_match is None: + return config + + model_updates: dict[str, object] = {} + if starter_match.capabilities is not None: + model_updates["capabilities"] = starter_match.capabilities + if starter_match.default_settings is not None: + model_updates["default_settings"] = starter_match.default_settings + if starter_match.panel_schema is not None: + model_updates["panel_schema"] = starter_match.panel_schema + + if not model_updates: + return config + + return config.model_copy(update=model_updates) def prepare_model_config_for_response(config: AnyModelConfig, dependencies: Type[ApiDependencies]) -> AnyModelConfig: diff --git a/invokeai/app/services/external_generation/external_generation_default.py b/invokeai/app/services/external_generation/external_generation_default.py index 1e1305a2509..506c0020fdc 100644 --- a/invokeai/app/services/external_generation/external_generation_default.py +++ b/invokeai/app/services/external_generation/external_generation_default.py @@ -26,8 +26,8 @@ ExternalGenerationResult, ExternalProviderStatus, ) -from invokeai.app.services.external_generation.starter_overrides import apply_external_starter_overrides from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalImageSize +from invokeai.backend.model_manager.starter_models import STARTER_MODELS if TYPE_CHECKING: from invokeai.app.services.model_records import ModelRecordServiceBase @@ -188,7 +188,7 @@ def _refresh_model_capabilities(self, request: ExternalGenerationRequest) -> Ext if record.provider_model_id != request.model.provider_model_id: return request - record = apply_external_starter_overrides(record) + record = _apply_starter_overrides(record) if record == request.model: return request @@ -402,3 +402,18 @@ def _resize_result_images(result: ExternalGenerationResult, width: int, height: provider_metadata=result.provider_metadata, content_filters=result.content_filters, ) + + +def _apply_starter_overrides(model: ExternalApiModelConfig) -> ExternalApiModelConfig: + source = model.source or f"external://{model.provider_id}/{model.provider_model_id}" + starter_match = next((starter for starter in STARTER_MODELS if starter.source == source), None) + if starter_match is None: + return model + updates: dict[str, object] = {} + if starter_match.capabilities is not None: + updates["capabilities"] = starter_match.capabilities + if starter_match.default_settings is not None: + updates["default_settings"] = starter_match.default_settings + if not updates: + return model + return model.model_copy(update=updates) diff --git a/invokeai/app/services/external_generation/starter_overrides.py b/invokeai/app/services/external_generation/starter_overrides.py deleted file mode 100644 index b1a3ddac67f..00000000000 --- a/invokeai/app/services/external_generation/starter_overrides.py +++ /dev/null @@ -1,30 +0,0 @@ -from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities -from invokeai.backend.model_manager.starter_models import STARTER_MODELS - -_UNSET_CAPABILITIES = ExternalModelCapabilities() - - -def apply_external_starter_overrides(config: ExternalApiModelConfig) -> ExternalApiModelConfig: - """Fill a starter-sourced record's unset fields from its starter definition. - - A record that never set a field (the default capability matrix, no default settings, no panel schema) - follows the shipped starter definition, so definition updates reach it. A field the record sets, by an - install that copied the definition or by an edit in the model editor, is the record's own and wins. - """ - source = config.source or f"external://{config.provider_id}/{config.provider_model_id}" - starter_match = next((starter for starter in STARTER_MODELS if starter.source == source), None) - if starter_match is None: - return config - - updates: dict[str, object] = {} - if starter_match.capabilities is not None and config.capabilities == _UNSET_CAPABILITIES: - updates["capabilities"] = starter_match.capabilities - if starter_match.default_settings is not None and config.default_settings is None: - updates["default_settings"] = starter_match.default_settings - if starter_match.panel_schema is not None and config.panel_schema is None: - updates["panel_schema"] = starter_match.panel_schema - - if not updates: - return config - - return config.model_copy(update=updates) diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 9346ae161b1..8375d3172bd 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -1362,7 +1362,10 @@ "defaultHeight": "Default height", "defaultNumImages": "Default image count", "unlimited": "No limit", - "providerDefault": "Provider default" + "providerDefault": "Provider default", + "supported": "Supported", + "notSupported": "Not supported", + "starterManaged": "Managed by the starter catalog; the provider IDs can still be edited." }, "queueAttentionCount_one": "{{count}} needs attention", "queueAttentionCount_other": "{{count}} need attention", diff --git a/invokeai/frontend/webv2/src/features/models/core/externalModelFields.test.ts b/invokeai/frontend/webv2/src/features/models/core/externalModelFields.test.ts index 22b4a53ea48..09f09f8c083 100644 --- a/invokeai/frontend/webv2/src/features/models/core/externalModelFields.test.ts +++ b/invokeai/frontend/webv2/src/features/models/core/externalModelFields.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest'; import type { ExternalModelSettingsModel } from './externalModelFields'; -import { toExternalModelChanges, toExternalModelValues, validateExternalModel } from './externalModelFields'; +import { + isStarterManagedExternalModel, + toExternalModelChanges, + toExternalModelValues, + toExternalProviderChanges, + validateExternalModel, +} from './externalModelFields'; const geminiModel: ExternalModelSettingsModel = { capabilities: { @@ -22,6 +28,7 @@ const geminiModel: ExternalModelSettingsModel = { name: 'Gemini 3 Pro', provider_id: 'gemini', provider_model_id: 'gemini-3-pro-image-preview', + source: 'external://gemini/gemini-3-pro-image-preview', type: 'external_image_generator', }; @@ -113,4 +120,37 @@ describe('external model record mapping', () => { ); expect(validateExternalModel({ ...values, defaultWidth: 1024.5 })).toBe('Default width must be a whole number.'); }); + + it('recognises a record the starter catalog manages by its source, or by the source an install would record', () => { + const starters = [ + { source: 'external://openai/gpt-image-1' }, + { source: 'external://gemini/gemini-2.5-flash-image' }, + ]; + + expect( + isStarterManagedExternalModel( + { provider_id: 'openai', provider_model_id: 'renamed', source: 'external://openai/gpt-image-1' }, + starters + ) + ).toBe(true); + expect(isStarterManagedExternalModel({ provider_id: 'openai', provider_model_id: 'gpt-image-1' }, starters)).toBe( + true + ); + expect( + isStarterManagedExternalModel( + { provider_id: 'openai', provider_model_id: 'gpt-image-1', source: 'external://custom/gpt-image-1' }, + starters + ) + ).toBe(false); + expect(isStarterManagedExternalModel({ provider_id: 'custom', provider_model_id: 'mine' }, [])).toBe(false); + }); + + it('sends only the routing ids for a starter-managed record', () => { + const values = toExternalModelValues(geminiModel); + + expect(toExternalProviderChanges({ ...values, providerId: ' gemini ', providerModelId: 'flash ' })).toEqual({ + provider_id: 'gemini', + provider_model_id: 'flash', + }); + }); }); diff --git a/invokeai/frontend/webv2/src/features/models/core/externalModelFields.ts b/invokeai/frontend/webv2/src/features/models/core/externalModelFields.ts index bf146f074f1..f1a1760c02c 100644 --- a/invokeai/frontend/webv2/src/features/models/core/externalModelFields.ts +++ b/invokeai/frontend/webv2/src/features/models/core/externalModelFields.ts @@ -1,16 +1,33 @@ import type { ExternalModelFormValues } from './schemas'; -import type { ExternalModelCapabilities, ModelConfig, ModelRecordChanges } from './types'; +import type { ExternalModelCapabilities, ModelConfig, ModelRecordChanges, StarterModel } from './types'; import { externalModelSchema } from './schemas'; export type ExternalModelSettingsModel = Pick< ModelConfig, - 'capabilities' | 'default_settings' | 'key' | 'name' | 'provider_id' | 'provider_model_id' | 'type' + 'capabilities' | 'default_settings' | 'key' | 'name' | 'provider_id' | 'provider_model_id' | 'source' | 'type' >; export const supportsExternalModelSettings = (model: Pick): boolean => model.type === 'external_image_generator'; +/** + * Whether the starter catalog manages the record's capabilities and defaults: the backend overlays the catalog's + * values on every response for a record whose source names a starter, so editing them would not persist. Records + * without a source are matched by the source an external install would record. + */ +export const isStarterManagedExternalModel = ( + model: Pick & { source?: string | null }, + starters: readonly Pick[] +): boolean => { + const source = + typeof model.source === 'string' && model.source !== '' + ? model.source + : `external://${model.provider_id ?? ''}/${model.provider_model_id ?? ''}`; + + return starters.some((starter) => starter.source === source); +}; + const toOptionalInteger = (value: number | null | undefined): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null; @@ -75,6 +92,14 @@ export const toExternalModelChanges = ( }; }; +/** The PATCH body for a starter-managed record: only the routing ids persist, so only they are sent. */ +export const toExternalProviderChanges = ( + values: ExternalModelFormValues +): Pick => ({ + provider_id: values.providerId.trim(), + provider_model_id: values.providerModelId.trim(), +}); + /** The first schema message, like the other record editors' messages, so the UI shows it as is. */ export const validateExternalModel = (values: ExternalModelFormValues): string | null => { const result = externalModelSchema.safeParse(values); diff --git a/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.browser.test.tsx b/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.browser.test.tsx index 51bc8a8222d..ba0f425bbd4 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.browser.test.tsx @@ -19,6 +19,12 @@ const api = vi.hoisted(() => ({ ]) ), getModelsDir: vi.fn(() => Promise.resolve('')), + getStarterModels: vi.fn(() => + Promise.resolve({ + starter_bundles: {}, + starter_models: [{ source: 'external://gemini/gemini-2.5-flash-image' }], + }) + ), listMissingModels: vi.fn(() => Promise.resolve([])), listModels: vi.fn(() => Promise.resolve([])), updateModel: vi.fn(), @@ -44,6 +50,7 @@ const gptImage: ExternalModelSettingsModel = { name: 'GPT Image 1', provider_id: 'openai', provider_model_id: 'gpt-image-1', + source: 'external://openai/gpt-image-1', type: 'external_image_generator', }; @@ -60,6 +67,7 @@ describe('ExternalModelSection', () => { const onError = vi.fn(); const onSaved = vi.fn(); + // The editor is read-only until the starter catalog has answered, so every render waits for it. const render = async (model: ExternalModelSettingsModel) => { await act(() => { root.render( @@ -68,6 +76,7 @@ describe('ExternalModelSection', () => { ); }); + await settle(); }; const input = (label: string) => host.querySelector(`input[aria-label="${label}"]`)!; @@ -218,6 +227,37 @@ describe('ExternalModelSection', () => { expect(getModelDraft('gpt-image-1')?.external?.fields).toEqual({ providerId: 'seedream' }); }); + it('shows a starter-managed record read-only and saves only its routing ids', async () => { + const geminiFlash: ExternalModelSettingsModel = { + ...gptImage, + capabilities: { max_images_per_request: 1, modes: ['txt2img'], supports_reference_images: true }, + key: 'gemini-flash', + provider_id: 'gemini', + provider_model_id: 'gemini-2.5-flash-image', + source: 'external://gemini/gemini-2.5-flash-image', + }; + api.updateModel.mockImplementation((_key: string, changes: Record) => + Promise.resolve({ ...geminiFlash, ...changes }) + ); + await render(geminiFlash); + + expect(host.querySelector('[role="note"]')?.textContent).toBe('models.externalFields.starterManaged'); + expect(host.querySelectorAll('input[type="checkbox"]')).toHaveLength(0); + expect(host.querySelector('input[aria-label="models.externalFields.maxImagesPerRequest"]')).toBeNull(); + expect(host.textContent).toContain('models.externalModes.txt2img'); + expect(host.textContent).toContain('1024'); + + await type(host.querySelector('input[value="gemini-2.5-flash-image"]')!, 'gemini-3-pro'); + await click(button('models.saveExternalSettings')); + + expect(api.updateModel).toHaveBeenCalledWith( + 'gemini-flash', + { provider_id: 'gemini', provider_model_id: 'gemini-3-pro' }, + expect.anything() + ); + expect(onSaved).toHaveBeenCalledOnce(); + }); + it('surfaces a failed save and keeps the edits for another attempt', async () => { api.updateModel.mockRejectedValue(new Error('Model not found')); await render(gptImage); diff --git a/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.tsx b/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.tsx index 93b8ab57c64..0d500576c10 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/detail/ExternalModelSection.tsx @@ -14,8 +14,10 @@ import { Text, } from '@chakra-ui/react'; import { + isStarterManagedExternalModel, toExternalModelChanges, toExternalModelValues, + toExternalProviderChanges, validateExternalModel, } from '@features/models/core/externalModelFields'; import { updateModel } from '@features/models/data/api'; @@ -24,6 +26,7 @@ import { useExternalProvidersSelector, } from '@features/models/data/externalProvidersStore'; import { replaceModelInStore } from '@features/models/data/modelsStore'; +import { ensureStartersLoaded, useStartersSelector } from '@features/models/data/startersStore'; import { applyModelDraftFields, beginModelDraftSave, @@ -50,8 +53,10 @@ import { DraftConflictNotice, UnsavedChangesLabel } from './ModelDraftNotices'; /** * Edits the hosted-provider record: which provider and model ID requests route to, the modes and inputs the model - * advertises, its limits, and the size/count the provider is asked for by default. Unsaved values live in the - * model's retained draft; `externalModelFields` owns the record mapping and validation. + * advertises, its limits, and the size/count the provider is asked for by default. A record the starter catalog + * manages shows its capabilities and defaults read-only, since the backend reapplies the catalog's on every + * response; its routing ids still persist. Unsaved values live in the model's retained draft; `externalModelFields` + * owns the record mapping and validation. */ const selectExternalDraft = (draft: ModelDraft | undefined) => draft?.external; @@ -88,6 +93,15 @@ const DEFAULT_FIELDS: readonly { key: OptionalNumberKey; labelKey: string }[] = { key: 'defaultNumImages', labelKey: 'models.externalFields.defaultNumImages' }, ]; +const ReadOnlyValue = ({ label, value }: { label: string; value: string }) => ( + + + {label} + + {value} + +); + const OptionalNumberField = ({ disabled, label, @@ -140,6 +154,12 @@ export const ExternalModelSection = ({ const [error, setError] = useState(null); const { run } = useScopedAction(); const providerConfigs = useExternalProvidersSelector((snapshot) => snapshot.configs); + const starters = useStartersSelector((snapshot) => snapshot.response?.starter_models ?? null); + const startersStatus = useStartersSelector((snapshot) => snapshot.status); + // Read-only until the catalog says otherwise, so an edit never flips under the user; a catalog that cannot be + // loaded lets the editor through rather than locking custom records. + const isStarterManaged = + starters === null ? startersStatus !== 'error' : isStarterManagedExternalModel(model, starters); // The registered providers are what requests can route to; a record's own id stays offered even when it is not // among them, so an unknown provider reads as what it is rather than as nothing. const providerCollection = useMemo( @@ -158,6 +178,7 @@ export const ExternalModelSection = ({ const isDirty = hasDraftFields(draft); useMountEffect(() => { + ensureStartersLoaded(); // The list is a convenience: while it is unavailable the provider stays a text field. ensureExternalProvidersLoaded().catch(() => {}); }); @@ -188,7 +209,11 @@ export const ExternalModelSection = ({ return; } - const updated = await updateModel(model.key, toExternalModelChanges(submitted, model), owner.signal); + const updated = await updateModel( + model.key, + isStarterManaged ? toExternalProviderChanges(submitted) : toExternalModelChanges(submitted, model), + owner.signal + ); assertAccountScopeCurrent(owner); replaceModelInStore(updated); @@ -276,59 +301,100 @@ export const ExternalModelSection = ({ /> - - - {t('models.externalFields.supportedModes')} - - - {MODE_FLAGS.map(({ key, labelKey }) => ( - setField(key, event.checked === true)} - > - - - {t(labelKey)} - - ))} - - - {t('models.externalFields.supportedModesHelp')} - - - - {SUPPORT_FLAGS.map(({ key, labelKey }) => ( - - - - {t(labelKey)} - - setField(key, event.checked)} - > - - - - - {t(labelKey)} - + {isStarterManaged ? ( + + {starters === null ? null : ( + + {t('models.externalFields.starterManaged')} + + )} + + saved[key]) + .map(({ labelKey }) => t(labelKey)) + .join(', ')} + /> + {SUPPORT_FLAGS.map(({ key, labelKey }) => ( + + ))} + {LIMIT_FIELDS.map(({ key, labelKey }) => ( + + ))} + {DEFAULT_FIELDS.map(({ key, labelKey }) => ( + + ))} + + + ) : ( + <> + + + {t('models.externalFields.supportedModes')} + + + {MODE_FLAGS.map(({ key, labelKey }) => ( + setField(key, event.checked === true)} + > + + + {t(labelKey)} + + ))} + + {t('models.externalFields.supportedModesHelp')} + - ))} - - - {LIMIT_FIELDS.map((field) => numberField(field, 'models.externalFields.unlimited'))} - - - {DEFAULT_FIELDS.map((field) => numberField(field, 'models.externalFields.providerDefault'))} - + + {SUPPORT_FLAGS.map(({ key, labelKey }) => ( + + + + {t(labelKey)} + + setField(key, event.checked)} + > + + + + + {t(labelKey)} + + + + ))} + + + {LIMIT_FIELDS.map((field) => numberField(field, 'models.externalFields.unlimited'))} + + + {DEFAULT_FIELDS.map((field) => numberField(field, 'models.externalFields.providerDefault'))} + + + )} ); }; diff --git a/invokeai/frontend/webv2/src/features/models/ui/detail/ModelDetail.tsx b/invokeai/frontend/webv2/src/features/models/ui/detail/ModelDetail.tsx index 833ac871d36..85c1d6ccc2d 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/detail/ModelDetail.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/detail/ModelDetail.tsx @@ -138,6 +138,7 @@ const selectExternalModel = (snapshot: ModelsSnapshot, modelKey: string): Extern name: model.name, provider_id: model.provider_id, provider_model_id: model.provider_model_id, + source: model.source, type: model.type, } : null; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts index 1dddb0ec5d0..c549ecc0b27 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts @@ -65,8 +65,10 @@ describe('resolveModelGrid before the capability table arrives', () => { it('answers for an external generator too, which will never get a row to wait for', () => { // A writer holds off on `null`, so answering it here would stop the bbox <-> dims sync for - // external providers for the whole session. - expect(resolveModelGrid('external')).toBe(DEFAULT_MODEL_GRID); + // external providers for the whole session. Providers bucket to their own size tables, so the + // bbox is pixel-exact rather than snapped to a local model grid. + expect(resolveModelGrid('external')).toBe(1); + expect(gridSizeForModelBase('external')).toBe(1); }); it('keeps the default for readers that must show something', () => { diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts index d157fd3f2f4..44d8b2cda8f 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts @@ -780,7 +780,8 @@ describe('runCanvasInvocation', () => { ); it('records a notice and dispatches no snapshot when the graph fails validation', async () => { - // External image generators are rejected by the graph compiler. + // The compiler rejects a hosted provider asked for a canvas mode its record does not advertise: the raster + // snapshot makes this an image-to-image run, and the record lists text-to-image only. const harness = makeHarness({ model: externalModel }); await runCanvasInvocation(harness.deps); @@ -788,7 +789,7 @@ describe('runCanvasInvocation', () => { expect(harness.submittedGraphs()).toHaveLength(0); const notices = harness.notices(); expect(notices).toHaveLength(1); - expect(notices[0]?.message).toContain('does not support canvas generation'); + expect(notices[0]?.message).toBe('OpenAI Image does not support image-to-image generation.'); expect(harness.dedupe.byKey).toHaveLength(0); expect(harness.dedupe.byHash).toHaveLength(0); expect(harness.releaseRasterSnapshot).toHaveBeenCalledTimes(1); diff --git a/tests/app/routers/test_model_manager.py b/tests/app/routers/test_model_manager.py index ab1a70ab9d1..90dbed3968b 100644 --- a/tests/app/routers/test_model_manager.py +++ b/tests/app/routers/test_model_manager.py @@ -194,54 +194,6 @@ def test_model_manager_gemini_starter_model_applies_reference_and_resolution_ove ] -def test_model_manager_external_record_edits_win_over_starter_overrides( - monkeypatch: Any, client: TestClient, mm2_model_manager: Any, mm2_app_config: Any -) -> None: - """A capability table or default settings the record sets are returned as stored, not the starter's.""" - config = ExternalApiModelConfig( - key="external_gemini_edited", - name="Gemini Edited", - provider_id="gemini", - provider_model_id="gemini-3.1-flash-image-preview", - capabilities=ExternalModelCapabilities(modes=["txt2img"]), - source="external://gemini/gemini-3.1-flash-image-preview", - ) - mm2_model_manager.store.add_model(config) - - services = type("Services", (), {})() - services.model_manager = mm2_model_manager - services.model_images = DummyModelImages() - services.configuration = mm2_app_config - services.logger = logging.getLogger("test") - - invoker = DummyInvoker(services) - monkeypatch.setattr("invokeai.app.api.routers.model_manager.ApiDependencies", MockApiDependencies(invoker)) - monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(invoker)) - - edited_capabilities = {"modes": ["txt2img", "img2img"], "max_reference_images": 3, "supports_seed": True} - edited_defaults = {"width": 2048, "height": 2048, "num_images": 1} - patch_response = client.patch( - "/api/v2/models/i/external_gemini_edited", - json={"capabilities": edited_capabilities, "default_settings": edited_defaults}, - ) - - assert patch_response.status_code == 200 - patched = patch_response.json() - assert patched["capabilities"]["modes"] == ["txt2img", "img2img"] - assert patched["capabilities"]["max_reference_images"] == 3 - assert patched["capabilities"]["allowed_aspect_ratios"] is None - assert patched["default_settings"] == edited_defaults - - get_response = client.get("/api/v2/models/i/external_gemini_edited") - - assert get_response.status_code == 200 - fetched = get_response.json() - assert fetched["capabilities"] == patched["capabilities"] - assert fetched["default_settings"] == edited_defaults - # Fields the record never set still follow the starter definition. - assert [control["name"] for control in fetched["panel_schema"]["prompts"]] == ["reference_images"] - - def _make_stats_services(ram_caches: dict) -> Any: class _Load: pass diff --git a/tests/app/services/external_generation/test_external_generation_service.py b/tests/app/services/external_generation/test_external_generation_service.py index ea181a9582d..2b6ef3b6ca5 100644 --- a/tests/app/services/external_generation/test_external_generation_service.py +++ b/tests/app/services/external_generation/test_external_generation_service.py @@ -228,55 +228,6 @@ def test_generate_happy_path() -> None: assert provider.last_request == request -class _RecordStore: - def __init__(self, record: ExternalApiModelConfig) -> None: - self._record = record - - def get_model(self, key: str) -> ExternalApiModelConfig: - assert key == self._record.key - return self._record - - -def _gemini_record(capabilities: ExternalModelCapabilities) -> ExternalApiModelConfig: - return ExternalApiModelConfig( - key="gemini_flash", - name="Gemini Flash", - provider_id="gemini", - provider_model_id="gemini-2.5-flash-image", - capabilities=capabilities, - ) - - -def test_generate_fills_an_unset_starter_record_from_its_definition() -> None: - starter = next(model for model in STARTER_MODELS if model.source == "external://gemini/gemini-2.5-flash-image") - record = _gemini_record(ExternalModelCapabilities()) - provider = DummyProvider("gemini", configured=True, result=ExternalGenerationResult(images=[])) - service = ExternalGenerationService( - {"gemini": provider}, logging.getLogger("test"), record_store=_RecordStore(record) - ) - - service.generate(_build_request(model=record)) - - assert provider.last_request is not None - assert provider.last_request.model.capabilities == starter.capabilities - assert provider.last_request.model.default_settings == starter.default_settings - - -def test_generate_honours_a_starter_record_edited_past_its_definition() -> None: - # The shipped definition is txt2img only; the record was edited to allow img2img. - record = _gemini_record(ExternalModelCapabilities(modes=["txt2img", "img2img"], supports_seed=True)) - provider = DummyProvider("gemini", configured=True, result=ExternalGenerationResult(images=[])) - service = ExternalGenerationService( - {"gemini": provider}, logging.getLogger("test"), record_store=_RecordStore(record) - ) - - service.generate(_build_request(model=record, mode="img2img", init_image=_make_image())) - - assert provider.last_request is not None - assert provider.last_request.model.capabilities.modes == ["txt2img", "img2img"] - assert provider.last_request.model.capabilities.allowed_aspect_ratios is None - - def test_generate_resizes_inpaint_result_to_original_init_size() -> None: model = _build_model(ExternalModelCapabilities(modes=["inpaint"])) request = _build_request( From 57273a4f5551ea53f9228735b67c58491a875b56 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 11 Oct 2026 03:21:37 -0600 Subject: [PATCH 4/4] chore(perf): re-record route baselines for the hosted-provider modules Request counts and chunk sets are unchanged; the provider core joins the editor's generation sources and the provider sections join the Generate widget's. --- .../performance/architecture-baseline.json | 23 +- .../webv2/performance/browser-baseline.json | 225 +++++++++--------- 2 files changed, 129 insertions(+), 119 deletions(-) diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index e8a49c4183f..eca2898b573 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -2,15 +2,15 @@ "build": { "launchpad": { "baseline": { - "brotliBytes": 794977, + "brotliBytes": 795865, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 793689, + "gzipBytes": 794662, "imageRawBytes": 44398, - "initialRawBytes": 2042114, - "largestAssetRawBytes": 749029, + "initialRawBytes": 2046539, + "largestAssetRawBytes": 752421, "otherAssetRawBytes": 0, - "ownedRawBytes": 98621, + "ownedRawBytes": 98845, "requestCount": 35, "scriptRequestCount": 19, "sourceOwners": [ @@ -346,15 +346,15 @@ }, "editor": { "baseline": { - "brotliBytes": 1171873, + "brotliBytes": 1175651, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 1165072, + "gzipBytes": 1168861, "imageRawBytes": 44398, - "initialRawBytes": 3208701, - "largestAssetRawBytes": 749029, + "initialRawBytes": 3222002, + "largestAssetRawBytes": 752421, "otherAssetRawBytes": 0, - "ownedRawBytes": 150716, + "ownedRawBytes": 151795, "requestCount": 72, "scriptRequestCount": 56, "sourceOwners": [ @@ -550,6 +550,7 @@ "source:src/features/generation/core/componentCompatibility.ts", "source:src/features/generation/core/conditioningRebalance.ts", "source:src/features/generation/core/dynamicPrompts.ts", + "source:src/features/generation/core/externalProvider.ts", "source:src/features/generation/core/graph.ts", "source:src/features/generation/core/graphBuilder.ts", "source:src/features/generation/core/krea2Conditioning.ts", @@ -1092,7 +1093,7 @@ "source": "src/app/WorkbenchApp.tsx" } }, - "capturedAt": "2026-10-09", + "capturedAt": "2026-10-11", "developmentInvalidation": { "platformUiBarrel": { "specifier": "@platform/ui", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 99f91f948c9..34d8535735e 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -1,6 +1,6 @@ { "browserExecutable": "/home/toast/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome", - "capturedAt": "2026-10-09", + "capturedAt": "2026-10-11", "routes": [ { "activatedResourceBaseline": { @@ -14,13 +14,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 130.60000000149012, + "domContentLoadedMedianMs": 159.79999999701977, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 130.79999999701977, - "longestTaskMaxMs": 82, + "loadMedianMs": 159.89999999850988, + "longestTaskMaxMs": 104, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -29,14 +29,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 0, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 204755, "requestCount": 30, - "scriptRawBytes": 1842561, + "scriptRawBytes": 1847465, "scriptRequestCount": 26, - "totalRawBytes": 2068864 + "totalRawBytes": 2078043 }, - "routeReadyMedianMs": 275.80000000447035, + "routeReadyMedianMs": 334.79999999701977, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -52,13 +52,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.10000000149012, + "domContentLoadedMedianMs": 167.5, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 125.20000000298023, - "longestTaskMaxMs": 81, + "loadMedianMs": 167.70000000298023, + "longestTaskMaxMs": 105, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -67,14 +67,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 0, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 204755, "requestCount": 30, - "scriptRawBytes": 1842561, + "scriptRawBytes": 1847465, "scriptRequestCount": 26, - "totalRawBytes": 2068864 + "totalRawBytes": 2078043 }, - "routeReadyMedianMs": 277, + "routeReadyMedianMs": 353.1000000014901, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -90,29 +90,29 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.5, + "domContentLoadedMedianMs": 156.20000000298023, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 125.70000000298023, - "longestTaskMaxMs": 173, + "loadMedianMs": 156.39999999850988, + "longestTaskMaxMs": 240, "owner": "workbench", - "projectSwitchMedianMs": 38.30000000447035, + "projectSwitchMedianMs": 55.19999999552965, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 204755, "requestCount": 88, - "scriptRawBytes": 4063609, + "scriptRawBytes": 4098968, "scriptRequestCount": 83, - "totalRawBytes": 4334310 + "totalRawBytes": 4373944 }, - "routeReadyMedianMs": 430.19999999552965, + "routeReadyMedianMs": 549.5, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -128,29 +128,29 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 129.60000000149012, + "domContentLoadedMedianMs": 152.60000000149012, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 129.69999999552965, - "longestTaskMaxMs": 180, + "loadMedianMs": 152.79999999701977, + "longestTaskMaxMs": 232, "owner": "workbench", - "projectSwitchMedianMs": 44.099999994039536, + "projectSwitchMedianMs": 58.899999998509884, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 204755, "requestCount": 88, - "scriptRawBytes": 4063609, + "scriptRawBytes": 4098968, "scriptRequestCount": 83, - "totalRawBytes": 4334310 + "totalRawBytes": 4373944 }, - "routeReadyMedianMs": 466.3999999985099, + "routeReadyMedianMs": 569, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -159,20 +159,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 305162, + "largestAssetRawBytes": 305163, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 514518, + "scriptRawBytes": 515192, "scriptRequestCount": 9, - "totalRawBytes": 514518 + "totalRawBytes": 515192 }, - "domContentLoadedMedianMs": 125.5, + "domContentLoadedMedianMs": 146.89999999850988, "id": "editor-canvas", - "layoutAckMedianMs": 2.6999999955296516, - "layoutReturnSwitchMedianMs": 57.899999998509884, - "layoutSwitchMedianMs": 76.60000000149012, - "loadMedianMs": 125.60000000149012, - "longestTaskMaxMs": 179, + "layoutAckMedianMs": 3.399999998509884, + "layoutReturnSwitchMedianMs": 65.5, + "layoutSwitchMedianMs": 89.30000000447035, + "loadMedianMs": 147.09999999403954, + "longestTaskMaxMs": 211, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -181,14 +181,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 204755, "requestCount": 97, - "scriptRawBytes": 4578127, + "scriptRawBytes": 4614160, "scriptRequestCount": 92, - "totalRawBytes": 4848828 + "totalRawBytes": 4889136 }, - "routeReadyMedianMs": 1153.7999999970198, + "routeReadyMedianMs": 1295.3999999985099, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -197,20 +197,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 305162, + "largestAssetRawBytes": 305163, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 514518, + "scriptRawBytes": 515192, "scriptRequestCount": 9, - "totalRawBytes": 514518 + "totalRawBytes": 515192 }, - "domContentLoadedMedianMs": 126.59999999403954, + "domContentLoadedMedianMs": 126.19999999552965, "id": "editor-canvas", "layoutAckMedianMs": 3.100000001490116, - "layoutReturnSwitchMedianMs": 52.70000000298023, - "layoutSwitchMedianMs": 72.20000000298023, - "loadMedianMs": 126.69999999552965, - "longestTaskMaxMs": 196, + "layoutReturnSwitchMedianMs": 53.899999998509884, + "layoutSwitchMedianMs": 78.5, + "loadMedianMs": 126.29999999701977, + "longestTaskMaxMs": 205, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -219,14 +219,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 204755, "requestCount": 97, - "scriptRawBytes": 4578127, + "scriptRawBytes": 4614160, "scriptRequestCount": 92, - "totalRawBytes": 4848828 + "totalRawBytes": 4889136 }, - "routeReadyMedianMs": 1147.1000000014901, + "routeReadyMedianMs": 1172, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -235,20 +235,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 187094, + "largestAssetRawBytes": 187100, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 420919, + "scriptRawBytes": 420925, "scriptRequestCount": 6, - "totalRawBytes": 436332 + "totalRawBytes": 436338 }, - "domContentLoadedMedianMs": 125, + "domContentLoadedMedianMs": 126.89999999850988, "id": "editor-workflow", - "layoutAckMedianMs": 2.7000000029802322, - "layoutReturnSwitchMedianMs": 38.600000001490116, - "layoutSwitchMedianMs": 82.60000000149012, - "loadMedianMs": 125.09999999403954, - "longestTaskMaxMs": 174, + "layoutAckMedianMs": 3, + "layoutReturnSwitchMedianMs": 39.600000001490116, + "layoutSwitchMedianMs": 74.90000000596046, + "loadMedianMs": 127, + "longestTaskMaxMs": 172, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -257,14 +257,14 @@ "cssRawBytes": 17572, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 204755, "requestCount": 95, - "scriptRawBytes": 4484528, + "scriptRawBytes": 4519893, "scriptRequestCount": 89, - "totalRawBytes": 4770642 + "totalRawBytes": 4810282 }, - "routeReadyMedianMs": 1171.6000000014901, + "routeReadyMedianMs": 1176.7999999970198, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "empty" }, @@ -273,20 +273,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 187094, + "largestAssetRawBytes": 187100, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 420919, + "scriptRawBytes": 420925, "scriptRequestCount": 6, - "totalRawBytes": 436332 + "totalRawBytes": 436338 }, - "domContentLoadedMedianMs": 123.70000000298023, + "domContentLoadedMedianMs": 125, "id": "editor-workflow", - "layoutAckMedianMs": 2.6999999955296516, - "layoutReturnSwitchMedianMs": 41.80000000447035, - "layoutSwitchMedianMs": 128.80000000447035, - "loadMedianMs": 123.80000000447035, - "longestTaskMaxMs": 259, + "layoutAckMedianMs": 2.899999998509884, + "layoutReturnSwitchMedianMs": 43.100000001490116, + "layoutSwitchMedianMs": 132.20000000298023, + "loadMedianMs": 125, + "longestTaskMaxMs": 276, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -295,14 +295,14 @@ "cssRawBytes": 17572, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 204755, "requestCount": 95, - "scriptRawBytes": 4484528, + "scriptRawBytes": 4519893, "scriptRequestCount": 89, - "totalRawBytes": 4770642 + "totalRawBytes": 4810282 }, - "routeReadyMedianMs": 1192.8999999985099, + "routeReadyMedianMs": 1216.2000000029802, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "representative" }, @@ -318,13 +318,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.79999999701977, + "domContentLoadedMedianMs": 126.59999999403954, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 41.100000001490116, - "loadMedianMs": 125.89999999850988, - "longestTaskMaxMs": 174, + "layoutSwitchMedianMs": 39.79999999701977, + "loadMedianMs": 126.59999999403954, + "longestTaskMaxMs": 178, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -333,14 +333,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 204755, "requestCount": 88, - "scriptRawBytes": 4063609, + "scriptRawBytes": 4098968, "scriptRequestCount": 83, - "totalRawBytes": 4334310 + "totalRawBytes": 4373944 }, - "routeReadyMedianMs": 1431.3999999985099, + "routeReadyMedianMs": 1420.6000000014901, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -356,13 +356,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 126.89999999850988, + "domContentLoadedMedianMs": 126.20000000298023, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 45.399999998509884, - "loadMedianMs": 127.09999999403954, - "longestTaskMaxMs": 170, + "layoutSwitchMedianMs": 46.80000000447035, + "loadMedianMs": 126.39999999850988, + "longestTaskMaxMs": 171, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -371,14 +371,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 204755, "requestCount": 88, - "scriptRawBytes": 4063609, + "scriptRawBytes": 4098968, "scriptRequestCount": 83, - "totalRawBytes": 4334310 + "totalRawBytes": 4373944 }, - "routeReadyMedianMs": 1396.3999999985099, + "routeReadyMedianMs": 1411.6000000014901, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } @@ -974,6 +974,7 @@ "source:src/features/generation/core/componentCompatibility.ts", "source:src/features/generation/core/conditioningRebalance.ts", "source:src/features/generation/core/dynamicPrompts.ts", + "source:src/features/generation/core/externalProvider.ts", "source:src/features/generation/core/graph.ts", "source:src/features/generation/core/graphBuilder.ts", "source:src/features/generation/core/krea2Conditioning.ts", @@ -1019,6 +1020,7 @@ "source:src/features/generation/ui/GenerateHeaderActions.tsx", "source:src/features/generation/ui/GenerateModelCard.tsx", "source:src/features/generation/ui/GeneratePresetsPopover.tsx", + "source:src/features/generation/ui/GenerateProviderOptionsSection.tsx", "source:src/features/generation/ui/GenerateRenderSection.tsx", "source:src/features/generation/ui/GenerateSettingsForm.tsx", "source:src/features/generation/ui/GenerateWidgetView.tsx", @@ -1070,6 +1072,7 @@ "source:src/features/generation/ui/shared/ConceptRow.tsx", "source:src/features/generation/ui/shared/ConditioningRebalanceBars.tsx", "source:src/features/generation/ui/shared/ConditioningRebalanceSparkline.tsx", + "source:src/features/generation/ui/shared/ExternalSizeSelect.tsx", "source:src/features/generation/ui/shared/GenerateCollapsibleSection.tsx", "source:src/features/generation/ui/shared/GenerateFieldContextMenu.tsx", "source:src/features/generation/ui/shared/GenerateToggleSwitch.tsx", @@ -2061,6 +2064,7 @@ "source:src/features/generation/core/componentCompatibility.ts", "source:src/features/generation/core/conditioningRebalance.ts", "source:src/features/generation/core/dynamicPrompts.ts", + "source:src/features/generation/core/externalProvider.ts", "source:src/features/generation/core/graph.ts", "source:src/features/generation/core/graphBuilder.ts", "source:src/features/generation/core/krea2Conditioning.ts", @@ -2106,6 +2110,7 @@ "source:src/features/generation/ui/GenerateHeaderActions.tsx", "source:src/features/generation/ui/GenerateModelCard.tsx", "source:src/features/generation/ui/GeneratePresetsPopover.tsx", + "source:src/features/generation/ui/GenerateProviderOptionsSection.tsx", "source:src/features/generation/ui/GenerateRenderSection.tsx", "source:src/features/generation/ui/GenerateSettingsForm.tsx", "source:src/features/generation/ui/GenerateWidgetView.tsx", @@ -2157,6 +2162,7 @@ "source:src/features/generation/ui/shared/ConceptRow.tsx", "source:src/features/generation/ui/shared/ConditioningRebalanceBars.tsx", "source:src/features/generation/ui/shared/ConditioningRebalanceSparkline.tsx", + "source:src/features/generation/ui/shared/ExternalSizeSelect.tsx", "source:src/features/generation/ui/shared/GenerateCollapsibleSection.tsx", "source:src/features/generation/ui/shared/GenerateFieldContextMenu.tsx", "source:src/features/generation/ui/shared/GenerateToggleSwitch.tsx", @@ -3307,6 +3313,7 @@ "source:src/features/generation/core/componentCompatibility.ts", "source:src/features/generation/core/conditioningRebalance.ts", "source:src/features/generation/core/dynamicPrompts.ts", + "source:src/features/generation/core/externalProvider.ts", "source:src/features/generation/core/graph.ts", "source:src/features/generation/core/graphBuilder.ts", "source:src/features/generation/core/krea2Conditioning.ts", @@ -3352,6 +3359,7 @@ "source:src/features/generation/ui/GenerateHeaderActions.tsx", "source:src/features/generation/ui/GenerateModelCard.tsx", "source:src/features/generation/ui/GeneratePresetsPopover.tsx", + "source:src/features/generation/ui/GenerateProviderOptionsSection.tsx", "source:src/features/generation/ui/GenerateRenderSection.tsx", "source:src/features/generation/ui/GenerateSettingsForm.tsx", "source:src/features/generation/ui/GenerateWidgetView.tsx", @@ -3403,6 +3411,7 @@ "source:src/features/generation/ui/shared/ConceptRow.tsx", "source:src/features/generation/ui/shared/ConditioningRebalanceBars.tsx", "source:src/features/generation/ui/shared/ConditioningRebalanceSparkline.tsx", + "source:src/features/generation/ui/shared/ExternalSizeSelect.tsx", "source:src/features/generation/ui/shared/GenerateCollapsibleSection.tsx", "source:src/features/generation/ui/shared/GenerateFieldContextMenu.tsx", "source:src/features/generation/ui/shared/GenerateToggleSwitch.tsx",