Add per-operation AI provider routing

Each operation (inpaint, txt2img, img2img, outpaint) can now use a different
provider. Resolution order: per-op override → global AI_PROVIDER default.

Example: txt2img→openai, inpaint→invokeai, everything else→invokeai default.

Backend:
- config.py: add AI_PROVIDER_INPAINT / TXT2IMG / IMG2IMG / OUTPAINT settings
- remote_provider.py: get_remote_provider(operation) resolves override then default;
  _build_provider() extracted as shared factory; _OP_FIELD maps op→setting name
- ai_tools.py: each endpoint passes its operation to _require_remote();
  GET /api/config runs per-op health checks concurrently, returns operations map
  and overrides; POST /api/config accepts and applies per-op override fields

Frontend:
- ai_provider_settings.js: four new selects (inpaint/txt2img/img2img/outpaint);
  persists to localStorage and sends per-op fields to POST /api/config
- provider-badge.js: shows override summary (e.g. "invokeai · txt2img→openai")
  and per-op health in tooltip
- .env.example: document per-op override env vars with examples

https://claude.ai/code/session_01B58MaJCU1R6KwBDJCp8AfN
This commit is contained in:
Claude
2026-06-09 18:14:12 +00:00
parent b2a12c356f
commit d01c11f948
6 changed files with 191 additions and 62 deletions
@@ -34,8 +34,19 @@ export async function mountProviderBadge(container) {
dot.style.background = '#44cc44';
badge.style.background = '#1a2a1a';
badge.style.color = '#aaffaa';
label.textContent = remote.provider + (local.gpu_detected ? ' · GPU' : ' · CPU');
badge.title = 'Remote provider: ' + remote.provider + '\nCapabilities: ' + (remote.capabilities || []).join(', ');
// Show override summary if any operations use different providers
var overrides = remote.overrides || {};
var overrideEntries = Object.entries(overrides).filter(([, v]) => v);
var overrideStr = overrideEntries.length
? ' · ' + overrideEntries.map(([k, v]) => `${k}${v}`).join(', ')
: '';
label.textContent = remote.provider + overrideStr + (local.gpu_detected ? ' · GPU' : '');
var opLines = Object.entries(remote.operations || {})
.map(([op, s]) => `${op}: ${s.provider || remote.provider} ${s.healthy ? '✓' : '✗'}`)
.join('\n');
badge.title = opLines || ('Provider: ' + remote.provider);
} else if (remote.provider && !remote.healthy) {
dot.style.background = '#ffaa00';
badge.style.background = '#2a2000';
@@ -47,11 +47,44 @@ class Tools_ai_provider_settings_class {
},
{
name: 'provider',
title: 'Remote provider:',
title: 'Default provider (used unless overridden below):',
value: ls_get('provider', remote.provider || ''),
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
type: 'select',
},
// ── Per-operation overrides ───────────────────────────────
{
title: '',
html: '<div style="font-size:11px;color:#888;margin:2px 0 6px;">Per-operation overrides — blank = use default above</div>',
},
{
name: 'provider_inpaint',
title: 'Inpaint / Replace Selection:',
value: ls_get('provider_inpaint', remote.overrides?.inpaint || ''),
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
type: 'select',
},
{
name: 'provider_txt2img',
title: 'Text → Image:',
value: ls_get('provider_txt2img', remote.overrides?.txt2img || ''),
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
type: 'select',
},
{
name: 'provider_img2img',
title: 'Image → Image:',
value: ls_get('provider_img2img', remote.overrides?.img2img || ''),
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
type: 'select',
},
{
name: 'provider_outpaint',
title: 'Expand Canvas (Outpaint):',
value: ls_get('provider_outpaint', remote.overrides?.outpaint || ''),
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
type: 'select',
},
// ── OpenAI ────────────────────────────────────────────────
{
name: 'openai_key',
@@ -108,7 +141,11 @@ class Tools_ai_provider_settings_class {
async _save(params) {
// Persist to localStorage
ls_set('provider', params.provider || '');
ls_set('provider', params.provider || '');
ls_set('provider_inpaint', params.provider_inpaint || '');
ls_set('provider_txt2img', params.provider_txt2img || '');
ls_set('provider_img2img', params.provider_img2img || '');
ls_set('provider_outpaint', params.provider_outpaint || '');
ls_set('openai_key', params.openai_key || '');
ls_set('openai_model', params.openai_model || 'dall-e-3');
ls_set('invokeai_url', params.invokeai_url || '');
@@ -117,17 +154,21 @@ class Tools_ai_provider_settings_class {
ls_set('comfyui_model', params.comfyui_model || 'v1-5-pruned-emaonly.ckpt');
ls_set('replicate_key', params.replicate_key || '');
// Push to backend (requires a running server that accepts runtime config)
// Push to backend
try {
var payload = {
ai_provider: params.provider || '',
openai_api_key: params.openai_key || '',
openai_model: params.openai_model || 'dall-e-3',
invokeai_url: params.invokeai_url || '',
invokeai_default_model: params.invokeai_model || 'flux-dev',
comfyui_url: params.comfyui_url || '',
comfyui_default_model: params.comfyui_model || '',
replicate_api_key: params.replicate_key || '',
ai_provider: params.provider || '',
ai_provider_inpaint: params.provider_inpaint || '',
ai_provider_txt2img: params.provider_txt2img || '',
ai_provider_img2img: params.provider_img2img || '',
ai_provider_outpaint: params.provider_outpaint || '',
openai_api_key: params.openai_key || '',
openai_model: params.openai_model || 'dall-e-3',
invokeai_url: params.invokeai_url || '',
invokeai_default_model: params.invokeai_model || 'flux-dev',
comfyui_url: params.comfyui_url || '',
comfyui_default_model: params.comfyui_model || '',
replicate_api_key: params.replicate_key || '',
};
var base = window.API_BASE_URL || '';
var r = await fetch(`${base}/api/config`, {