lucida 1.1.0

Generate images and video with Google Gemini, Veo, Runway, Kling, a local ComfyUI, FLUX, Stability AI or OpenAI — a CLI and an MCP server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
//! What every image provider has in common, and what it does not.
//!
//! Lucida spoke only to Google for its first release, and `ImageRequest` quietly
//! encoded Google's answer to "what is an image request": named aspect ratios,
//! `1K`/`2K`/`4K` sizes, no seed, no negative prompt. A second provider disagrees
//! about all four.
//!
//! The resolution here is deliberately not a union type pretending to be a common
//! interface. It is three separate moves:
//!
//! 1. **Normalize what genuinely maps.** Aspect ratio and size are held as a
//!    ratio and a target long edge, which both providers can express — Google as
//!    its named strings, ComfyUI as pixel dimensions.
//! 2. **Declare what does not, and fail before spending anything.** A provider
//!    publishes [`Capabilities`], and a request carrying a parameter the provider
//!    cannot honour is rejected up front with a message naming one that can.
//!    Silently dropping the parameter is the failure mode worth ruling out: the
//!    user asked for a seed and got an unrepeatable image, with nothing said.
//! 3. **Report what differs downstream.** Provenance is not a request parameter
//!    but it varies per provider, and callers deserve to know which they got.

use anyhow::{Result, bail};
use std::fmt;

/// An image request, in terms every provider can be asked to interpret.
///
/// Fields past `references` are the ones providers disagree about. Each is
/// optional, and each is guarded by [`Capabilities::check`] rather than being
/// quietly ignored by a provider that has no such concept.
#[derive(Debug, Clone, Default)]
pub struct ImageRequest {
    pub prompt: String,
    pub model: String,
    pub aspect: Option<Aspect>,
    pub size: Option<Size>,
    /// Existing images to condition on. Supplying any turns this into an edit.
    pub references: Vec<String>,
    pub negative_prompt: Option<String>,
    /// A mask naming which part of the first reference to change.
    ///
    /// The roadmap predicted from the beginning that `references: Vec<String>`
    /// could not express "this region of this image", and it was right — it
    /// survived ComfyUI editing and BFL editing because both change the whole
    /// picture. OpenAI is where it stopped being deferrable.
    ///
    /// A raster image rather than a rectangle or a polygon, because that is what
    /// every provider that supports masking actually takes.
    pub mask: Option<String>,
    /// A provider-native workflow file to render with, instead of the built-in
    /// graph.
    ///
    /// The typed escape hatch the roadmap held in reserve for "genuinely
    /// provider-specific parameters", and the first thing to need it. A ComfyUI
    /// workflow is not a parameter any other provider could interpret, and
    /// pretending otherwise would mean inventing a graph format nobody speaks.
    pub workflow: Option<String>,
    pub seed: Option<u64>,
    pub steps: Option<u32>,
    pub guidance: Option<f32>,
}

#[derive(Debug)]
pub struct GeneratedImage {
    pub bytes: Vec<u8>,
    pub mime_type: String,
    /// Models often narrate what they drew; worth surfacing, never required.
    pub commentary: Option<String>,
    /// The seed actually used, when the provider has the concept. Providers that
    /// pick one at random should report it, so a result can be reproduced even
    /// though the request did not pin it.
    pub seed: Option<u64>,
}

/// An aspect ratio, kept as the pair it was written as rather than a float, so
/// `16:9` can be handed back to a provider that wants exactly that string.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Aspect {
    pub w: u32,
    pub h: u32,
}

impl Aspect {
    pub fn parse(text: &str) -> Result<Self> {
        let (w, h) = text
            .split_once(':')
            .ok_or_else(|| anyhow::anyhow!("aspect ratio `{text}` is not in W:H form, e.g. 16:9"))?;
        let parse = |part: &str, which| -> Result<u32> {
            part.trim()
                .parse::<u32>()
                .ok()
                .filter(|n| *n > 0)
                .ok_or_else(|| anyhow::anyhow!("the {which} of aspect ratio `{text}` is not a positive whole number"))
        };
        Ok(Self {
            w: parse(w, "width")?,
            h: parse(h, "height")?,
        })
    }

    fn ratio(self) -> f64 {
        f64::from(self.w) / f64::from(self.h)
    }
}

impl fmt::Display for Aspect {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}:{}", self.w, self.h)
    }
}

/// A target size, as the long edge in pixels.
///
/// Google names three tiers; everyone else takes pixel dimensions. Holding the
/// pixel count and naming the tiers on top means both readings are available
/// without either provider having to understand the other's vocabulary.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Size(pub u32);

impl Size {
    pub const ONE_K: Size = Size(1024);
    pub const TWO_K: Size = Size(2048);
    pub const FOUR_K: Size = Size(4096);

    /// Accepts either a Google tier name (`2K`) or a plain pixel count (`1536`).
    pub fn parse(text: &str) -> Result<Self> {
        match text.trim().to_ascii_uppercase().as_str() {
            "1K" => Ok(Self::ONE_K),
            "2K" => Ok(Self::TWO_K),
            "4K" => Ok(Self::FOUR_K),
            other => other
                .parse::<u32>()
                .ok()
                .filter(|n| (16..=16384).contains(n))
                .map(Size)
                .ok_or_else(|| {
                    anyhow::anyhow!(
                        "size `{text}` is neither a tier (1K, 2K, 4K) nor a pixel \
                         count between 16 and 16384"
                    )
                }),
        }
    }

    /// The nearest Google tier name, for the provider that only speaks those.
    pub fn tier_name(self) -> &'static str {
        match self.0 {
            n if n <= 1536 => "1K",
            n if n <= 3072 => "2K",
            _ => "4K",
        }
    }
}

impl fmt::Display for Size {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}px", self.0)
    }
}

impl ImageRequest {
    /// Resolves aspect and size into concrete pixel dimensions, for the providers
    /// that take them.
    ///
    /// `multiple_of` exists because latent-space models cannot render arbitrary
    /// dimensions — Flux works in units of 16 pixels — so rounding belongs here
    /// rather than in each provider. The long edge is what the size names, and
    /// the short edge follows from the ratio.
    pub fn pixels(&self, default: (u32, u32), multiple_of: u32) -> (u32, u32) {
        let (w, h) = match (self.aspect, self.size) {
            (None, None) => default,
            (None, Some(size)) => {
                // No ratio asked for: keep the default's shape, scale to the size.
                let long = default.0.max(default.1).max(1);
                let scale = f64::from(size.0) / f64::from(long);
                (
                    (f64::from(default.0) * scale) as u32,
                    (f64::from(default.1) * scale) as u32,
                )
            }
            (Some(aspect), size) => {
                let long = size.unwrap_or(Size(default.0.max(default.1))).0;
                if aspect.ratio() >= 1.0 {
                    (long, (f64::from(long) / aspect.ratio()) as u32)
                } else {
                    ((f64::from(long) * aspect.ratio()) as u32, long)
                }
            }
        };
        (round_to(w, multiple_of), round_to(h, multiple_of))
    }
}

fn round_to(value: u32, multiple: u32) -> u32 {
    if multiple <= 1 {
        return value.max(1);
    }
    let rounded = ((value + multiple / 2) / multiple) * multiple;
    rounded.max(multiple)
}

/// How a provider expresses aspect ratio, which decides what it can be asked for.
#[derive(Debug, Clone, Copy)]
pub enum AspectSupport {
    /// Only these exact ratios, and nothing between them.
    Named(&'static [&'static str]),
    /// Any ratio, subject to rounding to `multiple_of` pixels.
    Free { multiple_of: u32 },
}

/// What a provider embeds in its output.
///
/// Not a request parameter, but it varies per provider and it is the kind of
/// difference users find out about by grepping the bytes, which is how we found
/// out. Reporting it is cheaper than that.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provenance {
    /// An invisible SynthID watermark plus a C2PA manifest asserting
    /// `trainedAlgorithmicMedia`. Verified in Google's raw bytes; there is no
    /// opt-out on any tier. Paid tiers remove only the *visible* glyph.
    SynthIdAndC2pa,
    /// A signed C2PA manifest and **no** pixel watermark.
    ///
    /// The distinction from `SynthIdAndC2pa` is not pedantry, it is the whole
    /// practical difference: C2PA rides in metadata and any re-encode drops it,
    /// while SynthID is in the pixels and is built to survive one. So this
    /// output is marked *removably*, and Google's is not.
    ///
    /// Verified in a real render: a `caBX` chunk naming `Black Forest Labs API`
    /// as claim generator, `FLUX.2` as software agent, and asserting
    /// `digitalSourceType: trainedAlgorithmicMedia` — with no SynthID anywhere.
    C2paOnly,
    /// Nothing embedded — verified, not assumed.
    Unmarked,
    /// Nobody has looked yet.
    ///
    /// Deliberately distinct from `Unmarked`. Every other variant here was
    /// established by grepping real output, and "we checked and found nothing"
    /// is a materially different claim from "we assume nothing is there" — the
    /// second is the kind of thing people repeat until it becomes folklore.
    ///
    /// Kept for exactly the moment it is now serving: it is where a new
    /// provider starts before anyone has rendered anything with it. BFL was the
    /// case in point — it shipped as `Unverified`, one render proved it
    /// [`Self::C2paOnly`], and the guess most people would have made (unmarked,
    /// like other non-Google generators) was wrong. Runway holds it now, as of
    /// 2026-08-09, and one paid render is what will retire it.
    Unverified,
}

impl Provenance {
    pub fn describe(self) -> &'static str {
        match self {
            Self::SynthIdAndC2pa => "invisible SynthID watermark + C2PA manifest",
            Self::C2paOnly => "C2PA manifest only — no pixel watermark, so a re-encode removes it",
            Self::Unmarked => "no watermark or provenance manifest",
            Self::Unverified => "unverified — nobody has checked this provider's output",
        }
    }
}

/// Whether a provider takes a mask, and whether the mask is a promise.
///
/// This was a `bool` until the review after v0.9.0, and that review is the whole
/// argument for the type. Both masking providers were `mask: true`, so the
/// difference between them — one concentrates a change, the other guarantees the
/// rest of the picture survives — could only live in prose. It lived in prose on
/// seven surfaces: the `--mask` help, `lucida models`, two MCP descriptions, the
/// `image_providers` probe, this module's own refusal text, and the shipped
/// skill. When the local lane learned to bind, exactly one of the seven was
/// updated, and the probe an agent is told to believe was among the six that
/// were not.
///
/// So the distinction is a value now, and every surface reporting it reads
/// [`Self::describe`] or [`mask_semantics`]. The same move [`Provenance`] made,
/// for the same reason: a capability that varies has to be a variant, or the
/// variation ends up in sentences nobody updates.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaskSupport {
    /// No mask at all.
    No,
    /// Accepted, and it concentrates the change without confining it.
    ///
    /// Measured on `gpt-image-1.5`: asking for a change inside a lower-right box
    /// moved 58/255 inside it and 29/255 outside — twice the change where it was
    /// asked for, and the rest of the picture regenerated anyway, losing an
    /// object nowhere near the mask. `gpt-image-2` concentrates 4.5x rather than
    /// 2x, which is better and still not a guarantee.
    Advisory,
    /// Accepted, and pixels outside it come back unchanged.
    ///
    /// Only reachable where Lucida builds the render graph itself, and not for
    /// free even there: conditioning through `InpaintModelConditioning` alone is
    /// merely advisory, measured at 23.8/255 outside the mask. Compositing the
    /// render back through the same mask takes the outside to **0.00/255**, mean
    /// and max, through the release binary.
    Binding,
}

impl MaskSupport {
    /// Whether a mask can be passed at all.
    pub fn accepted(self) -> bool {
        !matches!(self, Self::No)
    }

    /// The adjective, for prose supplying its own sentence.
    pub fn kind(self) -> &'static str {
        match self {
            Self::No => "not accepted",
            Self::Advisory => "advisory",
            Self::Binding => "binding",
        }
    }

    /// What the caller has to do about it, which is the part that differs.
    pub fn guarantee(self) -> &'static str {
        match self {
            Self::No => "no mask can be passed",
            Self::Advisory => {
                "the change is concentrated but not confined, and the rest of the \
                 picture is regenerated too — composite over the original yourself \
                 if untouched pixels matter"
            }
            Self::Binding => {
                "Lucida composites the render back through the mask, so pixels \
                 outside it come back unchanged — measured at 0.00/255, which \
                 leaves nothing for the caller to composite"
            }
        }
    }

    /// One line for a capability listing.
    pub fn describe(self) -> &'static str {
        match self {
            Self::No => "no",
            Self::Advisory => "accepted, advisory — the change is concentrated, not confined",
            Self::Binding => "accepted, binding — pixels outside it come back unchanged",
        }
    }
}

/// The providers whose mask means a given thing.
pub fn mask_providers(kind: MaskSupport) -> Vec<&'static str> {
    Backend::ALL
        .iter()
        .filter(|b| capabilities_for(**b, b.default_model()).mask == kind)
        .map(|b| b.name())
        .collect()
}

/// Every provider that takes a mask, the ones that guarantee something first.
///
/// Ordered rather than alphabetical because this list is read as a remedy, and
/// the provider whose mask actually binds is the better answer to "where can I
/// mask instead".
pub fn mask_accepting_providers() -> Vec<&'static str> {
    let mut names = mask_providers(MaskSupport::Binding);
    names.extend(mask_providers(MaskSupport::Advisory));
    names
}

/// Masking across providers, in generated sentences.
///
/// The paragraph every agent-facing surface needs, and the reason it is computed:
/// each of those surfaces already generated its *provider list* while
/// hand-writing the word "advisory" beside it, so the list stayed true through
/// two new providers and the semantics went wrong the first time they forked.
pub fn mask_semantics() -> String {
    let mut parts = Vec::new();
    for kind in [MaskSupport::Binding, MaskSupport::Advisory] {
        let names = mask_providers(kind);
        if !names.is_empty() {
            parts.push(format!(
                "On {} the mask is {}: {}",
                join_and(&names),
                kind.kind(),
                kind.guarantee()
            ));
        }
    }

    if parts.is_empty() {
        return "No provider currently accepts a mask.".to_string();
    }
    format!("{}.", parts.join(". "))
}

/// How long a clip a provider will make.
///
/// A third shape, because the two video providers disagree in the way that
/// matters: Veo offers three fixed lengths and Runway a continuous range. Held
/// as a value for the same reason `AspectSupport` is — the alternative is prose
/// saying "4, 6 or 8 seconds" in five places, four of which go wrong when a
/// second provider arrives.
#[derive(Debug, Clone, Copy)]
pub enum DurationSupport {
    /// Exactly these lengths, in seconds.
    Named(&'static [u32]),
    /// Any whole number of seconds between these, inclusive.
    Range { min: u32, max: u32 },
}

impl DurationSupport {
    pub fn accepts(self, seconds: u32) -> bool {
        match self {
            DurationSupport::Named(lengths) => lengths.contains(&seconds),
            DurationSupport::Range { min, max } => (min..=max).contains(&seconds),
        }
    }

    pub fn describe(self) -> String {
        match self {
            DurationSupport::Named(lengths) => {
                let seconds: Vec<String> = lengths.iter().map(u32::to_string).collect();
                format!("{} seconds", join_and(&seconds.iter().map(String::as_str).collect::<Vec<_>>()))
            }
            DurationSupport::Range { min, max } => format!("{min}-{max} seconds"),
        }
    }
}

/// What a video provider can be asked for.
///
/// Deliberately its own struct rather than a reuse of [`Capabilities`]. Video and
/// images disagree about almost everything that matters — there is no mask, no
/// negative prompt on some models, no steps or guidance anywhere, and a duration
/// that images have no concept of — so sharing one type would mean a struct where
/// half the fields are meaningless depending on which kind of request it is
/// describing. That is the union-pretending-to-be-an-interface this module's own
/// header rejects.
#[derive(Debug, Clone, Copy)]
pub struct VideoCapabilities {
    pub provider: &'static str,
    pub tagline: &'static str,
    pub aspect: AspectSupport,
    pub duration: DurationSupport,
    /// Whether a still can be animated, rather than only text rendered.
    pub image_to_video: bool,
    /// Whether text alone is enough. Not a given: Runway's gen4_turbo animates
    /// an image and cannot start from a prompt at all.
    pub text_to_video: bool,
    pub negative_prompt: bool,
    pub resolution: bool,
    pub seed: bool,
    /// Quality tiers this provider offers, cheapest first. Empty where the
    /// concept does not exist, which is everywhere but Kling.
    pub modes: &'static [&'static str],
    pub provenance: Provenance,
}

impl VideoCapabilities {
    /// Rejects a request carrying anything this provider cannot express.
    ///
    /// The video twin of [`Capabilities::check`], and tagged as a refusal for the
    /// same reason: it happens before the money moves, and video is the lane
    /// where money moves fastest.
    pub fn check(&self, req: &crate::video::VideoRequest) -> Result<()> {
        self.refuse(req)
            .map_err(|e| anyhow::Error::new(crate::out::Refused(format!("{e:#}"))))
    }

    fn refuse(&self, req: &crate::video::VideoRequest) -> Result<()> {
        let me = self.provider;

        if req.image.is_some() && !self.image_to_video {
            bail!("`{me}` cannot animate a still image; it renders from a prompt alone.");
        }

        if req.image.is_none() && !self.text_to_video {
            bail!(
                "`{me}` renders only from a still image, so it needs one to \
                 animate.\n\nPass an image, or use a model that starts from text."
            );
        }

        if let Some(seconds) = req.duration
            && !self.duration.accepts(seconds)
        {
            bail!(
                "`{me}` cannot render {seconds} seconds. It offers {}.",
                self.duration.describe()
            );
        }

        if req.negative_prompt.is_some() && !self.negative_prompt {
            bail!("`{me}` has no negative prompt, so what to keep out cannot be honoured.");
        }

        if req.resolution.is_some() && !self.resolution {
            bail!(
                "`{me}` does not take a resolution; the shape you ask for decides \
                 the pixel count."
            );
        }

        if req.seed.is_some() && !self.seed {
            bail!("`{me}` has no concept of a seed, so a render there cannot be repeated.");
        }

        if let Some(mode) = &req.mode {
            if self.modes.is_empty() {
                bail!(
                    "`{me}` has no quality tiers, so `--mode` cannot be honoured. \
                     Its models differ by id rather than by tier."
                );
            }
            if !self.modes.contains(&mode.as_str()) {
                bail!("`{me}` has no `{mode}` tier. It offers: {}.", self.modes.join(", "));
            }
        }

        if let Some(aspect) = req.aspect
            && let AspectSupport::Named(accepted) = self.aspect
            && !accepted.iter().any(|a| *a == aspect.to_string())
        {
            bail!(
                "`{me}` does not offer {aspect}. It accepts: {}.",
                accepted.join(", ")
            );
        }

        Ok(())
    }
}

/// Which backend serves a video request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoBackend {
    Google,
    Runway,
    Kling,
}

impl VideoBackend {
    pub const ALL: &'static [VideoBackend] =
        &[VideoBackend::Google, VideoBackend::Runway, VideoBackend::Kling];

    /// The setting that has to be present before this provider can be chosen
    /// for a user. Every video lane is hosted, so every one of them has one.
    /// See `Backend::credential` for why this asks about the account and not
    /// about whether the provider would work.
    pub fn credential(self) -> Option<&'static str> {
        match self {
            Self::Google => Some("GEMINI_API_KEY"),
            Self::Runway => Some("RUNWAY_API_KEY"),
            Self::Kling => Some("KLINGAI_API_KEY"),
        }
    }

    /// Whether this provider is one the user has credentials for.
    pub fn is_available(self) -> bool {
        match self.credential() {
            None => true,
            Some(key) => crate::config::var(key).is_some(),
        }
    }

    pub fn name(self) -> &'static str {
        match self {
            Self::Google => "google",
            Self::Runway => "runway",
            Self::Kling => "kling",
        }
    }

    pub fn default_model(self) -> &'static str {
        match self {
            Self::Google => crate::video::DEFAULT_VIDEO_MODEL,
            Self::Runway => crate::runway::DEFAULT_MODEL,
            Self::Kling => crate::kling::DEFAULT_MODEL,
        }
    }

    pub fn parse(name: &str) -> Result<Self> {
        match name.trim().to_ascii_lowercase().as_str() {
            "google" | "veo" | "gemini" => Ok(Self::Google),
            "runway" | "runwayml" => Ok(Self::Runway),
            "kling" | "klingai" => Ok(Self::Kling),
            other => bail!(
                "`{other}` is not a video provider. Available: {}.",
                Self::ALL.iter().map(|b| b.name()).collect::<Vec<_>>().join(", ")
            ),
        }
    }
}

/// What a video backend supports for a given model, without constructing one.
pub fn video_capabilities_for(backend: VideoBackend, model: &str) -> VideoCapabilities {
    match backend {
        VideoBackend::Google => crate::video::CAPABILITIES,
        VideoBackend::Runway => crate::runway::capabilities(model),
        VideoBackend::Kling => crate::kling::capabilities(model),
    }
}

/// Guesses the video backend from a model id, so `--provider` stays optional.
pub fn infer_video_backend(model: &str) -> VideoBackend {
    if crate::runway::is_runway_model(model) {
        VideoBackend::Runway
    } else if crate::kling::is_kling_model(model) {
        VideoBackend::Kling
    } else {
        VideoBackend::Google
    }
}

/// Which provider a render in flight belongs to, from its id alone.
///
/// Needed because `lucida check <id>` is handed nothing else, and the two
/// providers' ids are shaped differently enough to tell apart: Veo's are
/// `operations/...` and Runway's are bare UUIDs. Guessing is acceptable here
/// only because it is *correctable* — `--provider` overrides it, and the ledger
/// records which provider started each render, so `lucida ops` prints an
/// unambiguous command rather than relying on this at all.
///
/// A third provider using UUIDs would collide, and the fix then is the ledger
/// rather than a cleverer guess.
pub fn infer_video_backend_from_operation(operation: &str) -> VideoBackend {
    if operation.starts_with("operations/") || operation.starts_with("models/") {
        VideoBackend::Google
    } else if looks_like_uuid(operation) {
        VideoBackend::Runway
    } else if operation.len() >= 12 && operation.chars().all(|c| c.is_ascii_digit()) {
        // Kling's are long decimal ids — 915468728228253726. Distinct from both
        // of the above, which is luck rather than design and is why
        // `--provider` overrides this and the ledger records the truth.
        VideoBackend::Kling
    } else {
        VideoBackend::Google
    }
}

fn looks_like_uuid(text: &str) -> bool {
    let groups: Vec<&str> = text.split('-').collect();
    groups.len() == 5
        && [8, 4, 4, 4, 12] == groups.iter().map(|g| g.len()).collect::<Vec<_>>()[..]
        && text.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
}

/// A render in flight, and the one thing that can finish it.
///
/// Kept as a trait rather than folded into `ImageProvider` because the shape is
/// genuinely different: video submits and polls, where every image provider but
/// two returns bytes from one request.
pub trait VideoProvider {
    /// Starts a render and returns the id that will collect it.
    fn start(&self, req: &crate::video::VideoRequest) -> Result<String>;

    /// One non-blocking check.
    fn poll(&self, operation: &str) -> Result<crate::video::VideoStatus>;
}

/// A model whose provider has announced the date it stops working.
pub struct Retirement {
    /// Matched against the start of a model id, so one entry covers a family.
    pub prefix: &'static str,
    /// The date the provider published, as the provider wrote it.
    pub date: &'static str,
}

/// Every announced shutdown, in one place.
///
/// Declared rather than described, for the reason this codebase keeps
/// rediscovering: a date written into prose is a claim that goes false on a
/// schedule, silently, and in the six places nobody re-reads. Imagen's shutdown
/// was future-tense in five files at once; three OpenAI ids sat in `KNOWN_MODELS`
/// with their December date recorded nowhere at all, so `lucida models` listed
/// them exactly like the ones that will still exist next year.
///
/// Adding a row here annotates every surface that prints a model id, and
/// [`retirement_note`] gets the tense right on both sides of the date.
pub const RETIREMENTS: &[Retirement] = &[
    // Replaced by gemini-3.1-flash-image, which is already the default.
    Retirement { prefix: "imagen", date: "2026-08-17" },
    // The Gemini image PREVIEW ids. Google announced these 2026-05-28 for
    // shutdown on 2026-06-25, and the GA ids that replace them are already our
    // default and our `pro` alias.
    //
    // ⚠ Verified 2026-09-09 and worth knowing: Google's own ListModels STILL
    // RETURNS both of these, months after the announced date, so `lucida models`
    // lists them live. That is the reason to annotate rather than to filter them
    // out — the list stays whatever the provider says it is, and the note says
    // what the provider announced about it. Do not "fix" this by hiding them:
    // the disagreement is the provider's, and hiding it would hide it from the
    // person who has to decide whether to trust the id.
    //
    // Longest prefix first: `find` returns the first match, and the GA ids must
    // not be caught by these.
    Retirement { prefix: "gemini-3.1-flash-image-preview", date: "2026-06-25" },
    Retirement { prefix: "gemini-3-pro-image-preview", date: "2026-06-25" },
    // Veo. Announced 2026-06-15 for shutdown on 2026-06-30 — already past.
    // Every VIDEO_ALIASES entry points into the 3.1 family, so these only fire
    // when someone types a raw id, which is exactly when a 404 needs explaining.
    // `veo-3.0` and not `veo-3`, or it would swallow the 3.1 models we default to.
    Retirement { prefix: "veo-2.0", date: "2026-06-30" },
    Retirement { prefix: "veo-3.0", date: "2026-06-30" },
    // Announced alongside gpt-image-2, which is already the default.
    Retirement { prefix: "gpt-image-1.5", date: "2026-12-01" },
    Retirement { prefix: "gpt-image-1-mini", date: "2026-12-01" },
    Retirement { prefix: "chatgpt-image-latest", date: "2026-12-01" },
];

/// "retires 2026-08-17" while it still works; "retired 2026-08-17" afterwards.
///
/// The tense is computed, not written, which is the whole point: the same string
/// stays true the day after the date it names.
pub fn retirement_note(model: &str) -> Option<String> {
    let retirement = RETIREMENTS.iter().find(|r| model.starts_with(r.prefix))?;
    let verb = if past(retirement.date) { "retired" } else { "retires" };
    Some(format!("{verb} {}", retirement.date))
}

/// Whether a `YYYY-MM-DD` date is behind us.
///
/// UTC, and by whole days: a model does not stop working at a moment this
/// process could know precisely, and being a few hours early or late with the
/// word "retired" costs nothing. An unparseable date reads as future, so a typo
/// in the table can only ever understate.
fn past(date: &str) -> bool {
    crate::clock::unix_time(date).is_some_and(|midnight| crate::clock::now() >= midnight)
}

/// `a`, `a and b`, `a, b and c` — the form a sentence needs rather than a table.
pub fn join_and(names: &[&str]) -> String {
    match names.split_last() {
        None => "no providers".to_string(),
        Some((last, [])) => (*last).to_string(),
        Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
    }
}

/// What a provider can actually be asked for.
///
/// The point of publishing this is `check`: a parameter the provider cannot
/// honour becomes an error naming a provider that can, before anything is spent.
///
/// Deliberately a plain value each provider declares as a constant, rather than
/// something only a live client can answer. Whether Google has a seed is not a
/// fact about your credentials, and finding out should not require having any —
/// see [`capabilities_for`].
#[derive(Debug, Clone, Copy)]
pub struct Capabilities {
    pub provider: &'static str,
    /// One line on why a caller would choose this provider.
    ///
    /// Lives beside the measured capabilities rather than in the MCP schema
    /// because that is where it stays true: the schema's prose was hand-written
    /// while its enum was generated, and by the fourth provider it claimed there
    /// were two, listed three, and omitted the fourth entirely. An agent reads
    /// that and believes it.
    pub tagline: &'static str,
    pub aspect: AspectSupport,
    /// Whether the output size can be chosen at all.
    ///
    /// A late addition, and a reminder that "what varies between providers" is
    /// not knowable up front: Stability exposes `aspect_ratio` and *no*
    /// dimensions whatsoever, so `--size` there is not a value out of range but
    /// a concept the API does not have. Without this it was silently dropped.
    pub size: bool,
    pub seed: bool,
    pub negative_prompt: bool,
    pub references: bool,
    /// Whether the provider accepts a mask naming where to concentrate an edit,
    /// and what that mask guarantees.
    ///
    /// Deliberately not a `bool`, which is what it was while the answer was
    /// "openai, and it is advisory". Two providers mask now and they mean
    /// different things by it — see [`MaskSupport`], which is also where the
    /// measurements live.
    pub mask: MaskSupport,
    /// Whether the provider can render a caller-supplied workflow.
    pub workflow: bool,
    pub steps: bool,
    pub guidance: bool,
    pub provenance: Provenance,
}

impl Capabilities {
    /// Rejects a request carrying anything this provider cannot express.
    ///
    /// Every message names an alternative, because "unsupported" without a way
    /// forward just moves the search to the documentation. This is the same shape
    /// as the `veo-lite` negative-prompt guard, which earned its keep.
    ///
    /// Everything this produces is a *refusal* rather than a failure — the
    /// request was understood and declined before anything was spent — so the
    /// whole result is tagged as one, and the CLI exits 2 instead of 1. Tagged
    /// here rather than at each `bail!` because that is the definition of this
    /// function, and a site added later should not have to remember.
    pub fn check(&self, req: &ImageRequest) -> Result<()> {
        self.refuse(req)
            .map_err(|e| anyhow::Error::new(crate::out::Refused(format!("{e:#}"))))
    }

    fn refuse(&self, req: &ImageRequest) -> Result<()> {
        let me = self.provider;

        if req.size.is_some() && !self.size {
            bail!(
                "`{me}` does not let you choose the output size, so `--size` cannot \
                 be honoured.\n\n\
                 The size is fixed by the provider and follows from the shape you \
                 ask for — `--aspect 16:9` on stability returns 2016x1152, for \
                 instance. Use `--aspect` to control the shape, and `comfyui` or \
                 `bfl` if the pixel count itself matters. Lucida reports the size \
                 it actually wrote."
            );
        }

        if req.seed.is_some() && !self.seed {
            bail!(
                "`{me}` has no concept of a seed, so `--seed` cannot be honoured.\n\n\
                 Google never exposes one, which means results there are not \
                 reproducible by any means. Use `comfyui` (a local model, e.g. \
                 `--model klein`) or `bfl` when you need to render the same image \
                 twice."
            );
        }

        if req.negative_prompt.is_some() && !self.negative_prompt {
            // The remedy differs by provider, so it cannot be one sentence. Telling
            // a BFL user that "Gemini responds better to positive description" is
            // the kind of near-miss advice that wastes more time than silence.
            let remedy = match me {
                "google" => {
                    "Describe what you do want instead — Gemini responds to positive \
                     description far better than to exclusions."
                }
                "bfl" => {
                    "No FLUX endpoint accepts one — not flux-2-*, not flux-dev, not \
                     flux-pro-1.1. That is a limit of the hosted API rather than of \
                     Lucida: the local lane has a negative prompt only because \
                     ComfyUI builds the graph and can wire the conditioning itself."
                }
                _ => "This provider exposes no negative conditioning.",
            };
            bail!(
                "`{me}` does not accept a negative prompt for images.\n\n{remedy}\n\n\
                 Use the `comfyui` provider if you need one — there it is a real \
                 conditioning input."
            );
        }

        if req.steps.is_some() && !self.steps {
            bail!("{}", self.no_sampler("--steps", "a step count"));
        }

        if req.guidance.is_some() && !self.guidance {
            bail!("{}", self.no_sampler("--guidance", "a guidance scale"));
        }

        if req.workflow.is_some() && !self.workflow {
            bail!(
                "`{me}` has no workflow format, so `--workflow` cannot be \
                 honoured.\n\n\
                 A workflow is a provider-native description of how to render — \
                 only `comfyui` has one, because only there does Lucida build a \
                 graph rather than fill in a request."
            );
        }

        if req.mask.is_some() {
            if !self.mask.accepted() {
                // Both halves generated. The hand-written version of this named
                // only `openai` and called every mask advisory, which by v0.9.0
                // sent people away from the one provider whose mask binds.
                bail!(
                    "`{me}` does not accept a mask, so `--mask` cannot be \
                     honoured.\n\n\
                     Use one of: {}. What a mask guarantees differs between them, \
                     and that difference is usually the reason to prefer one:\n\n{}",
                    join_and(&mask_accepting_providers()),
                    mask_semantics()
                );
            }
            if req.references.is_empty() {
                bail!(
                    "`--mask` names which part of an image to change, but no image \
                     was given to change.\n\n\
                     Use `lucida edit <image> <prompt> --mask <mask.png>`."
                );
            }
        }

        if !req.references.is_empty() && !self.references {
            // The editors are computed, not listed: a hand-written list here
            // claimed "both providers" long after there were five.
            let editors: Vec<&str> = Backend::ALL
                .iter()
                .filter(|b| capabilities_for(**b, b.default_model()).references)
                .map(|b| b.name())
                .collect();
            bail!(
                "`{me}` cannot condition on reference images, so there is nothing \
                 for it to edit.\n\n\
                 Generate from a prompt instead, or edit with one of: {}.",
                editors.join(", ")
            );
        }

        if let (Some(aspect), AspectSupport::Named(allowed)) = (req.aspect, self.aspect) {
            let asked = aspect.to_string();
            if !allowed.contains(&asked.as_str()) {
                bail!(
                    "`{me}` supports only these aspect ratios: {}.\n\n\
                     `{asked}` is not among them. Either pick the nearest, or use \
                     the `comfyui` provider, which takes free dimensions.",
                    allowed.join(", ")
                );
            }
        }

        Ok(())
    }

    /// The message for a sampler control this provider will not accept.
    ///
    /// Split out because BFL made it a per-*model* fact rather than a
    /// per-provider one: `flux-2-flex` exposes the sampler and `flux-2-pro` does
    /// not, so saying "bfl does not expose a step count" would be false and would
    /// send someone away from a provider that could have served them.
    fn no_sampler(&self, flag: &str, what: &str) -> String {
        match self.provider {
            "bfl" => format!(
                "this FLUX model does not expose {what}, so `{flag}` cannot be \
                 honoured.\n\n\
                 Within Black Forest Labs only `flux-2-flex` and `flux-dev` do — try \
                 `--model flux-2-flex`. The others decide sampling for themselves. \
                 `lucida models --provider bfl` marks which is which."
            ),
            "google" => format!(
                "`google` does not expose {what}; the model decides how to sample.\n\n\
                 `{flag}` applies to `comfyui`, and to `bfl` on `flux-2-flex` or \
                 `flux-dev`."
            ),
            other => format!("`{other}` does not expose {what}, so `{flag}` cannot be honoured."),
        }
    }
}

/// A source of images.
pub trait ImageProvider {
    // No `capabilities()` here, deliberately, and it was removed rather than
    // never written: asking a *client* what its provider supports requires
    // having built one, which requires a credential, which is precisely the
    // coupling `capabilities_for` exists to break. The method survived because
    // its two callers already held a client — and both of them therefore gave
    // up before printing the table when no key was set. Use
    // `capabilities_for(backend, model)`; it needs neither.
    fn generate(&self, req: &ImageRequest) -> Result<GeneratedImage>;

    /// Models this provider can actually reach right now, for `lucida models`.
    fn list_models(&self) -> Result<Vec<String>>;
}

/// What a backend supports for a given model, without constructing one.
///
/// This is what lets `--seed` against Google report "google has no concept of a
/// seed" rather than "no API key found". The second message is true and useless:
/// supplying a key would not have helped.
///
/// Takes the model as well as the backend because BFL forced the issue — within
/// one provider, `steps` exists on `flux-2-flex` and not on `flux-2-pro`. Until
/// then a provider had one answer for everyone, and publishing the union would
/// have advertised parameters that some endpoints silently ignore.
pub fn capabilities_for(backend: Backend, model: &str) -> Capabilities {
    match backend {
        Backend::Google => crate::genai::CAPABILITIES,
        Backend::ComfyUi => crate::comfy::CAPABILITIES,
        Backend::Bfl => crate::bfl::capabilities(model),
        Backend::Stability => crate::stability::capabilities(model),
        Backend::OpenAi => crate::openai::capabilities(model),
    }
}

/// Which backend serves a request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
    Google,
    ComfyUi,
    Bfl,
    Stability,
    OpenAi,
}

impl Backend {
    pub fn parse(text: &str) -> Result<Self> {
        match text.trim().to_ascii_lowercase().as_str() {
            "google" | "gemini" => Ok(Self::Google),
            "comfyui" | "comfy" | "local" => Ok(Self::ComfyUi),
            "bfl" | "flux" | "blackforestlabs" => Ok(Self::Bfl),
            "stability" | "stabilityai" | "sai" => Ok(Self::Stability),
            "openai" | "oai" | "gpt" => Ok(Self::OpenAi),
            other => bail!(
                "unknown provider `{other}`. Known providers: {}",
                // Generated, so a sixth provider cannot be missing from it —
                // the way openai was missing from the hand-written version.
                Backend::ALL
                    .iter()
                    .map(|b| b.name())
                    .collect::<Vec<_>>()
                    .join(", ")
            ),
        }
    }

    pub fn name(self) -> &'static str {
        match self {
            Self::Google => "google",
            Self::ComfyUi => "comfyui",
            Self::Bfl => "bfl",
            Self::Stability => "stability",
            Self::OpenAi => "openai",
        }
    }

    /// How this provider is named to someone who has not used Lucida yet.
    ///
    /// [`name`](Self::name) is the token `--provider` takes, and it is not the
    /// same thing: `bfl` is a flag value, while the product is called FLUX and
    /// nobody searching for it types the company's initials. This is the
    /// spelling the shopfront surfaces use — the package description, the
    /// `--help` banner — and it exists so those can be *checked* against
    /// `Backend::ALL` rather than hand-maintained.
    ///
    /// That check is owed: the repository description read "Generate and edit
    /// images with Google's Gemini models" for four providers and all of video,
    /// while being the only pitch a visitor ever saw.
    ///
    /// Test-only on purpose. Nothing Lucida *prints* should use these — output
    /// names the token you can type — so a production caller would be a sign the
    /// two vocabularies had started to blur. Its job is to be the list a prose
    /// surface can be measured against.
    #[cfg(test)]
    pub fn product_name(self) -> &'static str {
        match self {
            Self::Google => "Gemini",
            Self::ComfyUi => "ComfyUI",
            Self::Bfl => "FLUX",
            Self::Stability => "Stability",
            Self::OpenAi => "OpenAI",
        }
    }

    /// The same, for a video backend. Separate because the two enums are
    /// separate, and `google` means Gemini in one and Veo in the other.
    #[cfg(test)]
    pub fn video_product_name(backend: VideoBackend) -> &'static str {
        match backend {
            VideoBackend::Google => "Veo",
            VideoBackend::Runway => "Runway",
            VideoBackend::Kling => "Kling",
        }
    }

    /// The model used when none is named. Lives here rather than in `main` so
    /// anything asking "what can this provider do" gets the same answer the CLI
    /// would give — asking BFL with an empty model reports no editing, because
    /// an empty string is not a FLUX.2 endpoint.
    pub fn default_model(self) -> &'static str {
        match self {
            Self::Google => crate::genai::DEFAULT_MODEL,
            Self::ComfyUi => "klein",
            Self::Bfl => crate::bfl::DEFAULT_MODEL,
            Self::Stability => crate::stability::DEFAULT_MODEL,
            Self::OpenAi => crate::openai::DEFAULT_MODEL,
        }
    }

    pub const ALL: &'static [Backend] = &[
        Backend::Google,
        Backend::ComfyUi,
        Backend::Bfl,
        Backend::Stability,
        Backend::OpenAi,
    ];

    /// The setting that has to be present before this provider can be *chosen
    /// for* a user, or `None` for a provider that needs no credential.
    ///
    /// This is deliberately not "can this provider work". It is "did the user
    /// tell us they have an account here", which is the only question a default
    /// may answer on its own. ComfyUI is the `None`: it runs locally and falls
    /// back to a built-in localhost URL, so listing it is itself the decision.
    pub fn credential(self) -> Option<&'static str> {
        match self {
            Self::Google => Some("GEMINI_API_KEY"),
            Self::ComfyUi => None,
            Self::Bfl => Some("BFL_API_KEY"),
            Self::Stability => Some("STABILITY_API_KEY"),
            Self::OpenAi => Some("OPENAI_API_KEY"),
        }
    }

    /// Whether this provider is one the user has credentials for.
    pub fn is_available(self) -> bool {
        match self.credential() {
            None => true,
            Some(key) => crate::config::var(key).is_some(),
        }
    }
}

/// Guesses the backend from a model id, so `--provider` stays optional.
///
/// Order matters here, and it is the fiddly part. A local checkpoint file and a
/// hosted endpoint can both be called something-flux, so exact aliases are
/// checked before any pattern, and a filename extension decides before a name
/// does. An unrecognised id falls to Google, which keeps every existing
/// invocation working and means a Gemini model released tomorrow works today.
pub fn infer_backend(model: &str) -> Backend {
    let key = model.trim().to_ascii_lowercase();

    if crate::comfy::MODEL_ALIASES.iter().any(|(a, _)| *a == key) {
        return Backend::ComfyUi;
    }
    if crate::stability::MODEL_ALIASES.iter().any(|(a, _)| *a == key)
        || crate::stability::KNOWN_MODELS.contains(&key.as_str())
        || crate::stability::SD3_VARIANTS.contains(&key.as_str())
    {
        return Backend::Stability;
    }
    if crate::openai::MODEL_ALIASES.iter().any(|(a, _)| *a == key)
        || crate::openai::KNOWN_MODELS.contains(&key.as_str())
    {
        return Backend::OpenAi;
    }
    if crate::bfl::MODEL_ALIASES.iter().any(|(a, _)| *a == key)
        || crate::bfl::KNOWN_MODELS.contains(&key.as_str())
    {
        return Backend::Bfl;
    }
    // A checkpoint file is unambiguously something ComfyUI loads.
    if key.ends_with(".safetensors") || key.ends_with(".gguf") {
        return Backend::ComfyUi;
    }
    // Anything else shaped like a BFL endpoint path — this is what lets a model
    // released tomorrow reach the right provider without a code change.
    if key.starts_with("flux-") {
        return Backend::Bfl;
    }

    Backend::Google
}

// ---------------------------------------------------------------------------
// Default provider preference
// ---------------------------------------------------------------------------

/// Where a defaulted provider came from.
///
/// Exists because a default that does not say so is the silent substitution
/// this tool refuses everywhere else. A render that was not told which provider
/// to use must be able to report which one it picked *and why*.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DefaultSource {
    /// The first entry in the user's ordered list that they hold a credential
    /// for. `position` is 1-based, so it reads the way a person counts.
    Preference {
        setting: &'static str,
        position: usize,
        of: usize,
    },
    /// No preference configured, so the built-in fall-through applies. This is
    /// what every invocation did before preferences existed.
    BuiltIn,
}

impl DefaultSource {
    /// One line naming the resolved provider and where the choice came from.
    pub fn describe(&self, chosen: &str) -> String {
        match self {
            Self::Preference {
                setting,
                position,
                of,
            } => format!("{chosen} (choice {position} of {of} in {setting})"),
            Self::BuiltIn => format!("{chosen} (built-in default; no preference set)"),
        }
    }

    /// The short tag that goes in `--json` output.
    pub fn tag(&self) -> &'static str {
        match self {
            Self::Preference { setting, .. } => setting,
            Self::BuiltIn => "built-in",
        }
    }
}

/// A provider set that a user can express an ordered preference over.
///
/// One trait rather than two copies of the walk below: the image and video
/// ladders differ only in their members, and a second copy is a second place
/// for the "never a fallback" rule to be got wrong.
pub trait Preferred: Copy + Sized + 'static {
    /// The setting holding the ordered list.
    const SETTING: &'static str;
    /// Where resolution lands when no preference is configured.
    const BUILT_IN: Self;

    fn parse_name(text: &str) -> Result<Self>;
    fn provider_name(self) -> &'static str;
    fn credential_setting(self) -> Option<&'static str>;
    fn available(self) -> bool;
    fn every() -> &'static [Self];
}

impl Preferred for Backend {
    const SETTING: &'static str = "LUCIDA_IMAGE_PROVIDERS";
    const BUILT_IN: Self = Backend::Google;

    fn parse_name(text: &str) -> Result<Self> {
        Self::parse(text)
    }
    fn provider_name(self) -> &'static str {
        self.name()
    }
    fn credential_setting(self) -> Option<&'static str> {
        self.credential()
    }
    fn available(self) -> bool {
        self.is_available()
    }
    fn every() -> &'static [Self] {
        Self::ALL
    }
}

impl Preferred for VideoBackend {
    const SETTING: &'static str = "LUCIDA_VIDEO_PROVIDERS";
    const BUILT_IN: Self = VideoBackend::Google;

    fn parse_name(text: &str) -> Result<Self> {
        Self::parse(text)
    }
    fn provider_name(self) -> &'static str {
        self.name()
    }
    fn credential_setting(self) -> Option<&'static str> {
        self.credential()
    }
    fn available(self) -> bool {
        self.is_available()
    }
    fn every() -> &'static [Self] {
        Self::ALL
    }
}

/// The ordered preference list as written, or `None` if the setting is unset.
///
/// Empty entries are skipped so a trailing comma is not an error worth
/// stopping for, but an unrecognised name is: a typo that silently dropped an
/// entry would move the render to the next provider, which is precisely the
/// substitution this design exists to avoid.
fn preference_list<T: Preferred>() -> Result<Option<Vec<T>>> {
    let Some(raw) = crate::config::var(T::SETTING) else {
        return Ok(None);
    };

    let mut chain = Vec::new();
    for entry in raw.split(',') {
        let entry = entry.trim();
        if entry.is_empty() {
            continue;
        }
        let parsed = T::parse_name(entry).map_err(|e| {
            anyhow::anyhow!(
                "{setting} lists `{entry}`, which is not a provider.\n\n{e}\n\n\
                 Fix the list rather than leaving it: a name nothing recognises \
                 would otherwise hand the render to whichever provider came \
                 next, which is not what you wrote down.",
                setting = T::SETTING,
            )
        })?;
        chain.push(parsed);
    }

    if chain.is_empty() {
        return Ok(None);
    }
    Ok(Some(chain))
}

/// Resolves the provider to use when the user named neither provider nor model.
///
/// ⚠ **This is a preference order, not a fallback chain, and the difference is
/// the whole design** (owner, 2026-08-09, reaffirmed 2026-09-08). The walk below
/// happens once, before any client exists and before anything is sent, and it
/// asks only "does the user hold a credential here". It never runs again. If the
/// provider it lands on then refuses a parameter, or the render fails, that is
/// the answer — moving to the next entry would spend money at a provider the
/// user never chose, and would undo the guarantee the capability system exists
/// to make.
pub fn resolve_default<T: Preferred>() -> Result<(T, DefaultSource)> {
    let Some(chain) = preference_list::<T>()? else {
        return Ok((T::BUILT_IN, DefaultSource::BuiltIn));
    };

    let of = chain.len();
    for (index, candidate) in chain.iter().copied().enumerate() {
        if candidate.available() {
            return Ok((
                candidate,
                DefaultSource::Preference {
                    setting: T::SETTING,
                    position: index + 1,
                    of,
                },
            ));
        }
    }

    // Every entry named, none usable. Falling through to the built-in here
    // would route to a provider the user deliberately left off their list, so
    // this refuses and says exactly which credential would settle it.
    let missing = chain
        .iter()
        .map(|c| match c.credential_setting() {
            Some(key) => format!("  {} needs {key}", c.provider_name()),
            None => format!("  {} needs no credential", c.provider_name()),
        })
        .collect::<Vec<_>>()
        .join("\n");

    // What the user *could* reach but did not list. Naming it turns a refusal
    // into one move rather than a hunt through the docs for which key is which.
    let elsewhere = T::every()
        .iter()
        .copied()
        .filter(|c| c.available() && !chain.iter().any(|listed| listed.provider_name() == c.provider_name()))
        .map(|c| c.provider_name())
        .collect::<Vec<_>>();

    let aside = if elsewhere.is_empty() {
        String::new()
    } else {
        format!(
            "\n\nYou do have credentials for {}, which {setting} does not list.",
            join_and(&elsewhere),
            setting = T::SETTING,
        )
    };

    bail!(
        "no provider in {setting} has a credential configured.\n\n{missing}{aside}\n\n\
         Set one of those keys, name a provider explicitly, or clear {setting} to \
         return to the built-in default ({built_in}).",
        setting = T::SETTING,
        built_in = T::BUILT_IN.provider_name(),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Every provider's credential names a setting Lucida actually reads.
    ///
    /// A provider declaring a key that is not in `KNOWN_KEYS` fails *closed and
    /// silently*: `is_available` would be false on every machine, so a
    /// preference listing it could never select it, and `lucida config` would
    /// never show the user the key they were missing. This repo has already paid
    /// for the neighbouring version of that bug — `LUCIDA_BUDGET` was enforced
    /// while `lucida config` reported it as "ignored — check the spelling".
    #[test]
    fn every_provider_credential_is_a_setting_lucida_knows() {
        let known: Vec<&str> = crate::config::KNOWN_KEYS.iter().map(|(k, _)| *k).collect();

        for backend in Backend::ALL {
            if let Some(key) = backend.credential() {
                assert!(
                    known.contains(&key),
                    "image provider {} wants {key}, which is not in KNOWN_KEYS — \
                     it can never be available and `lucida config` will never name it",
                    backend.name()
                );
            }
        }
        for backend in VideoBackend::ALL {
            if let Some(key) = backend.credential() {
                assert!(
                    known.contains(&key),
                    "video provider {} wants {key}, which is not in KNOWN_KEYS",
                    backend.name()
                );
            }
        }
    }

    /// The preference settings are themselves configurable settings.
    ///
    /// Held because the failure is invisible from the code that reads them:
    /// `config::var` answers for any string, so a preference absent from
    /// `KNOWN_KEYS` would work perfectly while `lucida config` filed it under
    /// "not recognised" — telling the user their list was being ignored at the
    /// moment it was deciding their renders.
    #[test]
    fn both_preference_settings_are_known_settings() {
        let known: Vec<&str> = crate::config::KNOWN_KEYS.iter().map(|(k, _)| *k).collect();
        for setting in [
            <Backend as Preferred>::SETTING,
            <VideoBackend as Preferred>::SETTING,
        ] {
            assert!(
                known.contains(&setting),
                "{setting} decides which provider a render uses and is not in KNOWN_KEYS"
            );
        }
    }

    /// Naming a video provider must supply that provider's model, not another's.
    ///
    /// `--model` carried a clap `default_value` of Veo's model id, so
    /// `--provider kling` with no model sent `veo-3.1-fast-generate-preview` to
    /// Kling — nothing could distinguish "unspecified" from "explicitly the Veo
    /// default". The image side had already solved this and its comment says so;
    /// video repeated the mistake anyway, which is what this pins.
    #[test]
    fn each_video_provider_supplies_its_own_default_model() {
        let mut seen = std::collections::BTreeSet::new();
        for backend in VideoBackend::ALL {
            let model = backend.default_model();
            assert!(!model.trim().is_empty(), "{} has no default", backend.name());
            assert!(
                seen.insert(model),
                "`{model}` is the default for two video providers"
            );
            // And the default must route back to the provider that owns it, or
            // `lucida video --model <that>` lands somewhere else entirely.
            assert_eq!(
                infer_video_backend(model),
                *backend,
                "`{model}` is {}'s default but infers as another provider",
                backend.name()
            );
        }
    }

    /// `lucida check <id>` is handed an id and nothing else, so the id's shape
    /// has to route it. Real ids from each provider.
    #[test]
    fn an_operation_id_routes_to_the_provider_that_issued_it() {
        assert_eq!(
            infer_video_backend_from_operation("operations/abc123"),
            VideoBackend::Google
        );
        assert_eq!(
            infer_video_backend_from_operation("4f1a2b3c-0000-4000-8000-000000000000"),
            VideoBackend::Runway
        );
        assert_eq!(
            infer_video_backend_from_operation("915468728228253726"),
            VideoBackend::Kling
        );
    }

    /// Every provider has a default and every provider must be able to say
    /// which model it is, from one place.
    ///
    /// `lucida models` marked it per-provider and only two of the five branches
    /// were ever written — google and bfl — so openai and stability listed their
    /// default indistinguishably from everything else. The annotation is
    /// generated from here now, which is what makes provider six free.
    #[test]
    fn every_backend_names_a_default_model() {
        let mut seen = std::collections::BTreeSet::new();
        for backend in Backend::ALL {
            let model = backend.default_model();
            assert!(
                !model.trim().is_empty(),
                "{} has no default model",
                backend.name()
            );
            assert!(
                seen.insert(model),
                "`{model}` is the default for two providers, so `--model` alone \
                 cannot say which was meant"
            );
        }
    }

    /// The claim `capabilities_for`'s own doc comment makes — that finding out
    /// whether Google has a seed should not require having a key — held for the
    /// function and not for the two commands that printed it. Both held a client
    /// first and gave up before the table when one could not be built.
    ///
    /// The trait no longer offers a `capabilities()` at all, so the coupling
    /// cannot come back by the same route. This checks what is left: every
    /// backend answers, for every model, with nothing constructed.
    #[test]
    fn every_capability_is_answerable_without_a_client() {
        for backend in Backend::ALL {
            for model in ["", "nonsense", backend.default_model()] {
                let caps = capabilities_for(*backend, model);
                assert!(
                    !caps.provider.is_empty() && !caps.tagline.is_empty(),
                    "{} answered emptily for `{model}`",
                    backend.name()
                );
            }
        }
    }

    /// The tense is the whole feature: the same string has to stay true the day
    /// after the date it names. Imagen's date is within days of this being
    /// written, so both branches are about to be exercised for real.
    #[test]
    fn a_retirement_note_reads_correctly_on_both_sides_of_its_date() {
        let note = retirement_note("imagen-4.0-generate-001").expect("imagen retires");
        assert!(note.contains("2026-08-17"), "{note}");
        assert_eq!(note.starts_with("retired"), past("2026-08-17"), "{note}");

        assert!(retirement_note("gpt-image-1.5").is_some());
        assert!(retirement_note("chatgpt-image-latest").is_some());
    }

    /// The models that are still current must not be annotated — a false
    /// retirement notice sends someone to migrate off the thing they should be
    /// using. `gpt-image-1` is the sharp case: it is not retiring, and it is a
    /// prefix of two ids that are.
    #[test]
    fn the_current_defaults_carry_no_retirement_note() {
        for backend in Backend::ALL {
            let model = backend.default_model();
            assert_eq!(
                retirement_note(model),
                None,
                "`{model}` is a default and is marked as retiring"
            );
        }
        assert_eq!(retirement_note("gpt-image-1"), None);

        // Video defaults too. `veo-3.0` is a retirement prefix and the default
        // is `veo-3.1-...`, so a prefix shortened to `veo-3` would swallow the
        // model we ship — the same trap `gpt-image-1` documents above.
        for backend in VideoBackend::ALL {
            let model = backend.default_model();
            assert_eq!(
                retirement_note(model),
                None,
                "`{model}` is a video default and is marked as retiring"
            );
        }

        // The GA Gemini image ids, whose PREVIEW twins are retired. These are
        // safe only because the retirement prefixes carry the `-preview` suffix;
        // trimming either to the GA name would mark the live model dead and send
        // people off the thing they should be using.
        for ga in ["gemini-3.1-flash-image", "gemini-3-pro-image"] {
            assert_eq!(
                retirement_note(ga),
                None,
                "`{ga}` is current and its preview twin's prefix has caught it"
            );
            assert!(
                retirement_note(&format!("{ga}-preview")).is_some(),
                "`{ga}-preview` is retired and carries no note"
            );
        }
    }

    /// Every announced date has to be reachable from a model id someone can
    /// actually name, or the row is decoration.
    #[test]
    fn every_retirement_matches_a_model_the_provider_lists() {
        for retirement in RETIREMENTS {
            assert!(
                crate::clock::unix_time(retirement.date).is_some(),
                "`{}` has an unparseable date: {}",
                retirement.prefix,
                retirement.date
            );
            assert!(
                retirement_note(retirement.prefix).is_some(),
                "`{}` matches no model id, not even its own prefix",
                retirement.prefix
            );
        }
    }

    #[test]
    fn aspect_round_trips_through_its_written_form() {
        assert_eq!(Aspect::parse("16:9").unwrap().to_string(), "16:9");
        assert!(Aspect::parse("16x9").is_err());
        assert!(Aspect::parse("16:0").is_err());
    }

    #[test]
    fn size_accepts_tiers_and_raw_pixels() {
        assert_eq!(Size::parse("2k").unwrap(), Size::TWO_K);
        assert_eq!(Size::parse("1536").unwrap(), Size(1536));
        assert!(Size::parse("8").is_err());
    }

    #[test]
    fn pixels_put_the_named_size_on_the_long_edge() {
        let req = ImageRequest {
            aspect: Some(Aspect::parse("16:9").unwrap()),
            size: Some(Size::ONE_K),
            ..Default::default()
        };
        assert_eq!(req.pixels((1024, 1024), 16), (1024, 576));

        let portrait = ImageRequest {
            aspect: Some(Aspect::parse("9:16").unwrap()),
            size: Some(Size::ONE_K),
            ..Default::default()
        };
        assert_eq!(portrait.pixels((1024, 1024), 16), (576, 1024));
    }

    #[test]
    fn pixels_round_to_the_latent_grid() {
        // 3:2 at 1K is 682.66 on the short edge, which Flux cannot render.
        let req = ImageRequest {
            aspect: Some(Aspect::parse("3:2").unwrap()),
            size: Some(Size::ONE_K),
            ..Default::default()
        };
        let (w, h) = req.pixels((1024, 1024), 16);
        assert_eq!((w, h), (1024, 688));
        assert_eq!(h % 16, 0);
    }

    #[test]
    fn an_empty_request_gets_the_providers_default() {
        let req = ImageRequest::default();
        assert_eq!(req.pixels((1024, 1024), 16), (1024, 1024));
    }

    #[test]
    fn check_rejects_a_seed_the_provider_cannot_honour() {
        // Derived from a real provider rather than spelled out: a literal here
        // has broken three times now, once per capability added, which is noise
        // that says nothing about the behaviour under test.
        let caps = Capabilities {
            seed: false,
            ..crate::comfy::CAPABILITIES
        };
        let req = ImageRequest {
            seed: Some(7),
            ..Default::default()
        };
        let error = caps.check(&req).unwrap_err().to_string();
        assert!(error.contains("comfyui"), "the message must name a way forward: {error}");
    }

    /// Stability is the only provider with no size control, and `--size` there
    /// would otherwise be silently dropped — the failure this design exists to
    /// prevent, found only because the API turned out to have no width or height
    /// field at all.
    #[test]
    fn a_provider_without_size_control_rejects_size() {
        let caps = capabilities_for(Backend::Stability, "core");
        assert!(!caps.size);
        let req = ImageRequest {
            size: Some(Size::TWO_K),
            ..Default::default()
        };
        let error = caps.check(&req).unwrap_err().to_string();
        assert!(error.contains("--size"));
        assert!(error.contains("--aspect"), "must name what it does support");

        // The others all take one.
        for backend in [Backend::Google, Backend::ComfyUi, Backend::Bfl] {
            assert!(capabilities_for(backend, "").size, "{backend:?} should take a size");
        }
    }

    #[test]
    fn backends_are_inferred_from_the_model_id() {
        assert_eq!(infer_backend("banana"), Backend::Google);
        // All three Stability endpoints, not just the two that happen to be
        // aliases — `core` used to fall through to Google and 404 there.
        assert_eq!(infer_backend("core"), Backend::Stability);
        assert_eq!(infer_backend("ultra"), Backend::Stability);
        // The sd3 variant spellings too, now that they are reachable model ids.
        assert_eq!(infer_backend("sd3.5-flash"), Backend::Stability);
        assert_eq!(infer_backend("gpt-image-2"), Backend::OpenAi);
        assert_eq!(infer_backend("gemini-3.1-flash-image"), Backend::Google);
        assert_eq!(infer_backend("klein"), Backend::ComfyUi);
        assert_eq!(infer_backend("some-model.safetensors"), Backend::ComfyUi);
        assert_eq!(infer_backend("flux-2-pro"), Backend::Bfl);
        assert_eq!(infer_backend("flux-max"), Backend::Bfl);
        // Unknown ids fall to Google so new Gemini models work the day they ship.
        assert_eq!(infer_backend("gemini-9-image"), Backend::Google);
        // …and an unknown flux-shaped id reaches BFL for the same reason.
        assert_eq!(infer_backend("flux-3-pro"), Backend::Bfl);
    }

    /// Every hosted provider rejects an uppercase model id — measured against
    /// all four live APIs, which 404 the path or report the model does not
    /// exist. So a typed `--model CORE` must reach the wire lowercased rather
    /// than as a rejection nobody can read. The four used to disagree about
    /// this, purely by accident.
    #[test]
    fn hosted_model_ids_reach_the_wire_lowercased() {
        assert_eq!(crate::stability::resolve_model("CORE"), "core");
        assert_eq!(crate::openai::resolve_model("GPT-IMAGE-2"), "gpt-image-2");
        assert_eq!(crate::bfl::resolve_model("FLUX-2-PRO"), "flux-2-pro");
        assert_eq!(
            crate::genai::resolve_model("GEMINI-3.1-FLASH-IMAGE"),
            "gemini-3.1-flash-image"
        );
        // An alias still resolves to its target, whatever the caller typed.
        assert_eq!(crate::bfl::resolve_model("FLUX"), crate::bfl::resolve_model("flux"));
    }

    /// The generated mask paragraph has to cover every provider that masks, and
    /// name the kind for each — that is the whole reason it is generated.
    ///
    /// What this would have caught: after v0.9.0 the local lane bound its mask
    /// and six of the seven surfaces that describe masking still said
    /// "advisory", because each of them generated the provider *list* beside a
    /// hand-written semantics claim. A provider added with a mask now appears
    /// here automatically, and one added with the wrong kind fails the openai
    /// pair test.
    #[test]
    fn the_mask_paragraph_covers_every_masking_provider_and_names_its_kind() {
        let text = mask_semantics();

        for backend in Backend::ALL {
            let caps = capabilities_for(*backend, backend.default_model());
            if caps.mask.accepted() {
                assert!(
                    text.contains(backend.name()),
                    "{} masks but is missing from the generated paragraph: {text}",
                    backend.name()
                );
                assert!(text.contains(caps.mask.kind()), "{text}");
            }
        }

        // The remedy order is load-bearing: a caller sent away from a provider
        // that cannot mask should be pointed first at the one whose mask is a
        // guarantee, which is the reason to prefer it.
        let accepting = mask_accepting_providers();
        assert_eq!(accepting.first(), Some(&"comfyui"), "{accepting:?}");
    }

    /// The refusal has to name a way forward, and — since v0.9.0 — the right one.
    ///
    /// The hand-written version named `openai` and called every mask advisory,
    /// so it sent people away from the only provider whose mask guarantees
    /// anything. Both halves are computed now.
    #[test]
    fn refusing_a_mask_names_the_provider_whose_mask_binds() {
        let caps = capabilities_for(Backend::Google, "");
        let req = ImageRequest {
            mask: Some("mask.png".into()),
            ..Default::default()
        };

        let error = caps.check(&req).unwrap_err().to_string();
        assert!(error.contains("comfyui"), "{error}");
        assert!(error.contains("binding"), "{error}");
    }

    /// A mask with nothing to apply it to is refused before the capability
    /// question, since "which provider" is not the problem there.
    #[test]
    fn a_mask_without_a_reference_image_says_so() {
        let caps = capabilities_for(Backend::OpenAi, "gpt-image-2");
        let req = ImageRequest {
            mask: Some("mask.png".into()),
            ..Default::default()
        };
        let error = caps.check(&req).unwrap_err().to_string();
        assert!(error.contains("no image"), "{error}");
    }

    /// The ambiguity worth pinning down: local checkpoints and hosted endpoints
    /// both get called something-flux, and picking wrong sends a paid request to
    /// the wrong place — or a local filename to a billing API.
    #[test]
    fn local_and_hosted_flux_names_do_not_collide() {
        // Bare family aliases are the local lane.
        assert_eq!(infer_backend("flux2"), Backend::ComfyUi);
        assert_eq!(infer_backend("flux-2"), Backend::ComfyUi);
        assert_eq!(infer_backend("flux2-klein"), Backend::ComfyUi);
        // Endpoint-shaped names are hosted.
        assert_eq!(infer_backend("flux-2-klein-9b"), Backend::Bfl);
        // A file is always local, whatever it is called.
        assert_eq!(infer_backend("flux-2-pro.safetensors"), Backend::ComfyUi);
    }
}